瀏覽代碼

Support custom validation in OidcLogoutAuthenticationProvider

- Similar to custom validation in OAuth2AuthorizationCodeRequestAuthenticationProvider

Closes gh-1693
Daniel Garnier-Moiroux 11 月之前
父節點
當前提交
acd4fd0227

+ 12 - 0
docs/modules/ROOT/pages/protocol-endpoints.adoc

@@ -554,6 +554,18 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h
 [TIP]
 `OidcClientInitiatedLogoutSuccessHandler` is the corresponding configuration in Spring Security’s OAuth2 Client support for configuring {spring-security-reference-base-url}/servlet/oauth2/login/advanced.html#oauth2login-advanced-oidc-logout[OpenID Connect 1.0 RP-Initiated Logout].
 
+[[oidc-logout-endpoint-customizing-logout-request-validation]]
+=== Customizing Logout Request Validation
+
+`OidcLogoutAuthenticationValidator` is the default validator used for validating specific OpenID Connect Logout request parameters used in the RP-Initiated Logout flow.
+The default implementation validates the `post_logout_redirect_uri` parameter.
+If validation fails, an `OAuth2AuthenticationException` is thrown.
+
+`OidcLogoutAuthenticationProvider` provides the ability to override the default logout request validation by supplying a custom authentication validator of type `Consumer<OidcLogoutAuthenticationContext>` to `setAuthenticationValidator()`.
+
+[IMPORTANT]
+If validation fails, the authentication validator *MUST* throw `OAuth2AuthenticationException`.
+
 [[oidc-user-info-endpoint]]
 == OpenID Connect 1.0 UserInfo Endpoint
 

+ 106 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationContext.java

@@ -0,0 +1,106 @@
+/*
+ * Copyright 2020-2024 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
+ *
+ *      https://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.server.authorization.oidc.authentication;
+
+import java.util.Map;
+import java.util.function.Consumer;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationContext;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link OAuth2AuthenticationContext} that holds an
+ * {@link OidcLogoutAuthenticationToken} and additional information and is used when
+ * validating the OpenID Connect RP-Initiated Logout Request parameters.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 1.4
+ * @see OAuth2AuthenticationContext
+ * @see OidcLogoutAuthenticationToken
+ * @see OidcLogoutAuthenticationProvider#setAuthenticationValidator(Consumer)
+ */
+public final class OidcLogoutAuthenticationContext implements OAuth2AuthenticationContext {
+
+	private final Map<Object, Object> context;
+
+	private OidcLogoutAuthenticationContext(Map<Object, Object> context) {
+		this.context = context;
+	}
+
+	@SuppressWarnings("unchecked")
+	@Nullable
+	@Override
+	public <V> V get(Object key) {
+		return hasKey(key) ? (V) this.context.get(key) : null;
+	}
+
+	@Override
+	public boolean hasKey(Object key) {
+		Assert.notNull(key, "key cannot be null");
+		return this.context.containsKey(key);
+	}
+
+	/**
+	 * Returns the {@link RegisteredClient registered client}.
+	 * @return the {@link RegisteredClient}
+	 */
+	public RegisteredClient getRegisteredClient() {
+		return get(RegisteredClient.class);
+	}
+
+	/**
+	 * Constructs a new {@link Builder} with the provided
+	 * {@link OidcLogoutAuthenticationToken}.
+	 * @param authentication the {@link OidcLogoutAuthenticationToken}
+	 * @return the {@link Builder}
+	 */
+	public static Builder with(OidcLogoutAuthenticationToken authentication) {
+		return new Builder(authentication);
+	}
+
+	/**
+	 * A builder for {@link OidcLogoutAuthenticationContext}.
+	 */
+	public static final class Builder extends AbstractBuilder<OidcLogoutAuthenticationContext, Builder> {
+
+		private Builder(Authentication authentication) {
+			super(authentication);
+		}
+
+		/**
+		 * Sets the {@link RegisteredClient registered client}.
+		 * @param registeredClient the {@link RegisteredClient}
+		 * @return the {@link Builder} for further configuration
+		 */
+		public Builder registeredClient(RegisteredClient registeredClient) {
+			return put(RegisteredClient.class, registeredClient);
+		}
+
+		/**
+		 * Builds a new {@link OidcLogoutAuthenticationContext}.
+		 * @return the {@link OidcLogoutAuthenticationContext}
+		 */
+		@Override
+		public OidcLogoutAuthenticationContext build() {
+			return new OidcLogoutAuthenticationContext(getContext());
+		}
+
+	}
+
+}

+ 29 - 6
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2023 the original author or authors.
+ * Copyright 2020-2024 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.
@@ -21,6 +21,7 @@ import java.security.NoSuchAlgorithmException;
 import java.security.Principal;
 import java.util.Base64;
 import java.util.List;
+import java.util.function.Consumer;
 
 import org.apache.commons.logging.Log;
 import org.apache.commons.logging.LogFactory;
@@ -70,6 +71,8 @@ public final class OidcLogoutAuthenticationProvider implements AuthenticationPro
 
 	private final SessionRegistry sessionRegistry;
 
+	private Consumer<OidcLogoutAuthenticationContext> authenticationValidator = new OidcLogoutAuthenticationValidator();
+
 	/**
 	 * Constructs an {@code OidcLogoutAuthenticationProvider} using the provided
 	 * parameters.
@@ -126,11 +129,11 @@ public final class OidcLogoutAuthenticationProvider implements AuthenticationPro
 				&& !oidcLogoutAuthentication.getClientId().equals(registeredClient.getClientId())) {
 			throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
 		}
-		if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri())
-				&& !registeredClient.getPostLogoutRedirectUris()
-					.contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) {
-			throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri");
-		}
+
+		OidcLogoutAuthenticationContext context = OidcLogoutAuthenticationContext.with(oidcLogoutAuthentication)
+			.registeredClient(registeredClient)
+			.build();
+		this.authenticationValidator.accept(context);
 
 		if (this.logger.isTraceEnabled()) {
 			this.logger.trace("Validated logout request parameters");
@@ -182,6 +185,26 @@ public final class OidcLogoutAuthenticationProvider implements AuthenticationPro
 		return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication);
 	}
 
+	/**
+	 * Sets the {@code Consumer} providing access to the
+	 * {@link OidcLogoutAuthenticationContext} and is responsible for validating specific
+	 * Open ID Connect RP-Initiated Logout Request parameters associated in the
+	 * {@link OidcLogoutAuthenticationToken}. The default authentication validator is
+	 * {@link OidcLogoutAuthenticationValidator}.
+	 *
+	 * <p>
+	 * <b>NOTE:</b> The authentication validator MUST throw
+	 * {@link OAuth2AuthenticationException} if validation fails.
+	 * @param authenticationValidator the {@code Consumer} providing access to the
+	 * {@link OidcLogoutAuthenticationContext} and is responsible for validating specific
+	 * Open ID Connect RP-Initiated Logout Request parameters
+	 * @since 1.4
+	 */
+	public void setAuthenticationValidator(Consumer<OidcLogoutAuthenticationContext> authenticationValidator) {
+		Assert.notNull(authenticationValidator, "authenticationValidator cannot be null");
+		this.authenticationValidator = authenticationValidator;
+	}
+
 	private SessionInformation findSessionInformation(Authentication principal, String sessionId) {
 		List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), true);
 		SessionInformation sessionInformation = null;

+ 73 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationValidator.java

@@ -0,0 +1,73 @@
+/*
+ * Copyright 2020-2024 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
+ *
+ *      https://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.server.authorization.oidc.authentication;
+
+import java.util.function.Consumer;
+
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.util.StringUtils;
+
+/**
+ * A {@code Consumer} providing access to the {@link OidcLogoutAuthenticationContext}
+ * containing an {@link OidcLogoutAuthenticationToken} and is the default
+ * {@link OidcLogoutAuthenticationProvider#setAuthenticationValidator(Consumer)
+ * authentication validator} used for validating specific OpenID Connect RP-Initiated
+ * Logout parameters used in the Authorization Code Grant.
+ *
+ * <p>
+ * The default implementation first validates {@link OidcIdToken#getAudience()}, and then
+ * {@link OidcLogoutAuthenticationToken#getPostLogoutRedirectUri()}. If validation fails,
+ * an {@link OAuth2AuthenticationException} is thrown.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @since 1.4
+ * @see OidcLogoutAuthenticationContext
+ * @see OidcLogoutAuthenticationToken
+ * @see OidcLogoutAuthenticationProvider#setAuthenticationValidator(Consumer)
+ */
+public final class OidcLogoutAuthenticationValidator implements Consumer<OidcLogoutAuthenticationContext> {
+
+	/**
+	 * The default validator for
+	 * {@link OidcLogoutAuthenticationToken#getPostLogoutRedirectUri()}.
+	 */
+	public static final Consumer<OidcLogoutAuthenticationContext> DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR = OidcLogoutAuthenticationValidator::validatePostLogoutRedirectUri;
+
+	private final Consumer<OidcLogoutAuthenticationContext> authenticationValidator = DEFAULT_POST_LOGOUT_REDIRECT_URI_VALIDATOR;
+
+	@Override
+	public void accept(OidcLogoutAuthenticationContext authenticationContext) {
+		this.authenticationValidator.accept(authenticationContext);
+	}
+
+	private static void validatePostLogoutRedirectUri(OidcLogoutAuthenticationContext authenticationContext) {
+		OidcLogoutAuthenticationToken oidcLogoutAuthentication = authenticationContext.getAuthentication();
+		RegisteredClient registeredClient = authenticationContext.getRegisteredClient();
+		if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri())
+				&& !registeredClient.getPostLogoutRedirectUris()
+					.contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) {
+			OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_REQUEST,
+					"OpenID Connect 1.0 Logout Request Parameter: post_logout_redirect_uri",
+					"https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling");
+			throw new OAuth2AuthenticationException(error);
+		}
+	}
+
+}

+ 32 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2023 the original author or authors.
+ * Copyright 2020-2024 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.
@@ -24,6 +24,7 @@ import java.util.Base64;
 import java.util.Collections;
 import java.util.Date;
 import java.util.List;
+import java.util.function.Consumer;
 
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
@@ -53,6 +54,7 @@ import org.springframework.security.oauth2.server.authorization.settings.Authori
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.BDDMockito.given;
 import static org.mockito.Mockito.mock;
@@ -314,6 +316,35 @@ public class OidcLogoutAuthenticationProviderTests {
 		verify(this.registeredClientRepository).findById(eq(authorization.getRegisteredClientId()));
 	}
 
+	@Test
+	void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authenticationValidator cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthenticationValidatorThenUsed() throws NoSuchAlgorithmException {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		String sessionId = "session-1";
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.subject(principal.getName())
+			.audience(Collections.singleton(registeredClient.getClientId()))
+			.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+			.expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+			.claim("sid", createHash(sessionId))
+			.build();
+
+		@SuppressWarnings("unchecked")
+		Consumer<OidcLogoutAuthenticationContext> authenticationValidator = mock(Consumer.class);
+		this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
+
+		authenticateValidIdToken(principal, registeredClient, sessionId, idToken);
+		verify(authenticationValidator).accept(any());
+	}
+
 	@Test
 	public void authenticateWhenMissingSubThenThrowOAuth2AuthenticationException() {
 		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");