Browse Source

Add OpenID Connect 1.0 Logout Endpoint

Closes gh-266
Joe Grandja 2 years ago
parent
commit
98e3fe807a
66 changed files with 2718 additions and 84 deletions
  1. 11 1
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/client/Client.java
  2. 5 3
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java
  3. 5 2
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java
  4. 5 1
      docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/client/JpaRegisteredClientRepository.java
  5. 3 2
      docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java
  6. 3 2
      docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java
  7. 2 1
      docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java
  8. 1 0
      docs/src/docs/asciidoc/guides/how-to-jpa.adoc
  9. 12 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java
  10. 8 2
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java
  11. 43 2
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java
  12. 7 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java
  13. 64 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClient.java
  14. 10 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationEndpointConfigurer.java
  15. 24 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java
  16. 29 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java
  17. 3 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java
  18. 15 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcConfigurer.java
  19. 219 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.java
  20. 14 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java
  21. 10 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimNames.java
  22. 35 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistration.java
  23. 16 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfiguration.java
  24. 12 2
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimAccessor.java
  25. 8 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimNames.java
  26. 10 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java
  27. 181 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProvider.java
  28. 170 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationToken.java
  29. 6 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/RegisteredClientOidcClientRegistrationConverter.java
  30. 2 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java
  31. 218 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java
  32. 2 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java
  33. 120 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java
  34. 25 3
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java
  35. 7 1
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java
  36. 13 2
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java
  37. 19 0
      oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java
  38. 1 0
      oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql
  39. 27 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java
  40. 31 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java
  41. 33 3
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java
  42. 5 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java
  43. 42 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java
  44. 2 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java
  45. 2 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java
  46. 57 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java
  47. 40 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java
  48. 17 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java
  49. 77 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java
  50. 426 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java
  51. 109 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java
  52. 7 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java
  53. 363 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java
  54. 4 1
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java
  55. 14 2
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java
  56. 14 4
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java
  57. 33 0
      oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java
  58. 1 0
      oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql
  59. 2 1
      samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java
  60. 14 1
      samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java
  61. 2 1
      samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java
  62. 14 1
      samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java
  63. 2 1
      samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java
  64. 14 1
      samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java
  65. 22 2
      samples/messages-client/src/main/java/sample/config/SecurityConfig.java
  66. 6 5
      samples/messages-client/src/main/resources/templates/index.html

+ 11 - 1
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/entity/client/Client.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -39,6 +39,8 @@ public class Client {
 	@Column(length = 1000)
 	private String redirectUris;
 	@Column(length = 1000)
+	private String postLogoutRedirectUris;
+	@Column(length = 1000)
 	private String scopes;
 	@Column(length = 2000)
 	private String clientSettings;
@@ -118,6 +120,14 @@ public class Client {
 		this.redirectUris = redirectUris;
 	}
 
+	public String getPostLogoutRedirectUris() {
+		return this.postLogoutRedirectUris;
+	}
+
+	public void setPostLogoutRedirectUris(String postLogoutRedirectUris) {
+		this.postLogoutRedirectUris = postLogoutRedirectUris;
+	}
+
 	public String getScopes() {
 		return scopes;
 	}

+ 5 - 3
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/repository/authorization/AuthorizationRepository.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 the original author or authors.
+ * Copyright 2022-2023 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.
@@ -30,10 +30,12 @@ public interface AuthorizationRepository extends JpaRepository<Authorization, St
 	Optional<Authorization> findByAuthorizationCodeValue(String authorizationCode);
 	Optional<Authorization> findByAccessTokenValue(String accessToken);
 	Optional<Authorization> findByRefreshTokenValue(String refreshToken);
+	Optional<Authorization> findByOidcIdTokenValue(String idToken);
 	@Query("select a from Authorization a where a.state = :token" +
 			" or a.authorizationCodeValue = :token" +
 			" or a.accessTokenValue = :token" +
-			" or a.refreshTokenValue = :token"
+			" or a.refreshTokenValue = :token" +
+			" or a.oidcIdTokenValue = :token"
 	)
-	Optional<Authorization> findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValue(@Param("token") String token);
+	Optional<Authorization> findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(@Param("token") String token);
 }

+ 5 - 2
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/authorization/JpaOAuth2AuthorizationService.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 the original author or authors.
+ * Copyright 2022-2023 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.
@@ -35,6 +35,7 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
 import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
 import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
@@ -88,7 +89,7 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 
 		Optional<Authorization> result;
 		if (tokenType == null) {
-			result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValue(token);
+			result = this.authorizationRepository.findByStateOrAuthorizationCodeValueOrAccessTokenValueOrRefreshTokenValueOrOidcIdTokenValue(token);
 		} else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) {
 			result = this.authorizationRepository.findByState(token);
 		} else if (OAuth2ParameterNames.CODE.equals(tokenType.getValue())) {
@@ -97,6 +98,8 @@ public class JpaOAuth2AuthorizationService implements OAuth2AuthorizationService
 			result = this.authorizationRepository.findByAccessTokenValue(token);
 		} else if (OAuth2ParameterNames.REFRESH_TOKEN.equals(tokenType.getValue())) {
 			result = this.authorizationRepository.findByRefreshTokenValue(token);
+		} else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) {
+			result = this.authorizationRepository.findByOidcIdTokenValue(token);
 		} else {
 			result = Optional.empty();
 		}

+ 5 - 1
docs/src/docs/asciidoc/examples/src/main/java/sample/jpa/service/client/JpaRegisteredClientRepository.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2022 the original author or authors.
+ * Copyright 2022-2023 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.
@@ -78,6 +78,8 @@ public class JpaRegisteredClientRepository implements RegisteredClientRepository
 				client.getAuthorizationGrantTypes());
 		Set<String> redirectUris = StringUtils.commaDelimitedListToSet(
 				client.getRedirectUris());
+		Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(
+				client.getPostLogoutRedirectUris());
 		Set<String> clientScopes = StringUtils.commaDelimitedListToSet(
 				client.getScopes());
 
@@ -94,6 +96,7 @@ public class JpaRegisteredClientRepository implements RegisteredClientRepository
 						authorizationGrantTypes.forEach(grantType ->
 								grantTypes.add(resolveAuthorizationGrantType(grantType))))
 				.redirectUris((uris) -> uris.addAll(redirectUris))
+				.postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
 				.scopes((scopes) -> scopes.addAll(clientScopes));
 
 		Map<String, Object> clientSettingsMap = parseMap(client.getClientSettings());
@@ -124,6 +127,7 @@ public class JpaRegisteredClientRepository implements RegisteredClientRepository
 		entity.setClientAuthenticationMethods(StringUtils.collectionToCommaDelimitedString(clientAuthenticationMethods));
 		entity.setAuthorizationGrantTypes(StringUtils.collectionToCommaDelimitedString(authorizationGrantTypes));
 		entity.setRedirectUris(StringUtils.collectionToCommaDelimitedString(registeredClient.getRedirectUris()));
+		entity.setPostLogoutRedirectUris(StringUtils.collectionToCommaDelimitedString(registeredClient.getPostLogoutRedirectUris()));
 		entity.setScopes(StringUtils.collectionToCommaDelimitedString(registeredClient.getScopes()));
 		entity.setClientSettings(writeMap(registeredClient.getClientSettings().getSettings()));
 		entity.setTokenSettings(writeMap(registeredClient.getTokenSettings().getSettings()));

+ 3 - 2
docs/src/docs/asciidoc/examples/src/test/java/sample/gettingStarted/SecurityConfigTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -102,7 +102,8 @@ public class SecurityConfigTests {
 		assertThatAuthorization(refreshToken, null).isNotNull();
 
 		String idToken = (String) tokenResponse.get(OidcParameterNames.ID_TOKEN);
-		assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNull(); // id_token is not searchable
+		assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNotNull();
+		assertThatAuthorization(idToken, null).isNotNull();
 
 		OAuth2Authorization authorization = findAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN);
 		assertThat(authorization.getToken(idToken)).isNotNull();

+ 3 - 2
docs/src/docs/asciidoc/examples/src/test/java/sample/jpa/JpaTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -117,7 +117,8 @@ public class JpaTests {
 		assertThatAuthorization(refreshToken, null).isNotNull();
 
 		String idToken = (String) tokenResponse.get(OidcParameterNames.ID_TOKEN);
-		assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNull(); // id_token is not searchable
+		assertThatAuthorization(idToken, OidcParameterNames.ID_TOKEN).isNotNull();
+		assertThatAuthorization(idToken, null).isNotNull();
 
 		OAuth2Authorization authorization = findAuthorization(accessToken, OAuth2ParameterNames.ACCESS_TOKEN);
 		assertThat(authorization.getToken(idToken)).isNotNull();

+ 2 - 1
docs/src/docs/asciidoc/examples/src/test/java/sample/util/RegisteredClients.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -37,6 +37,7 @@ public class RegisteredClients {
 				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
 				.redirectUri("http://127.0.0.1:8080/authorized")
+				.postLogoutRedirectUri("http://127.0.0.1:8080/index")
 				.scope(OidcScopes.OPENID)
 				.scope("message.read")
 				.scope("message.write")

+ 1 - 0
docs/src/docs/asciidoc/guides/how-to-jpa.adoc

@@ -45,6 +45,7 @@ CREATE TABLE client (
     clientAuthenticationMethods varchar(1000) NOT NULL,
     authorizationGrantTypes varchar(1000) NOT NULL,
     redirectUris varchar(1000) DEFAULT NULL,
+    postLogoutRedirectUris varchar(1000) DEFAULT NULL,
     scopes varchar(1000) NOT NULL,
     clientSettings varchar(2000) NOT NULL,
     tokenSettings varchar(2000) NOT NULL,

+ 12 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationService.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -26,6 +26,8 @@ import org.springframework.lang.Nullable;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
 import org.springframework.util.Assert;
 
 /**
@@ -150,6 +152,7 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
 			return matchesState(authorization, token) ||
 					matchesAuthorizationCode(authorization, token) ||
 					matchesAccessToken(authorization, token) ||
+					matchesIdToken(authorization, token) ||
 					matchesRefreshToken(authorization, token);
 		} else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) {
 			return matchesState(authorization, token);
@@ -157,6 +160,8 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
 			return matchesAuthorizationCode(authorization, token);
 		} else if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
 			return matchesAccessToken(authorization, token);
+		} else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) {
+			return matchesIdToken(authorization, token);
 		} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
 			return matchesRefreshToken(authorization, token);
 		}
@@ -185,6 +190,12 @@ public final class InMemoryOAuth2AuthorizationService implements OAuth2Authoriza
 		return refreshToken != null && refreshToken.getToken().getTokenValue().equals(token);
 	}
 
+	private static boolean matchesIdToken(OAuth2Authorization authorization, String token) {
+		OAuth2Authorization.Token<OidcIdToken> idToken =
+				authorization.getToken(OidcIdToken.class);
+		return idToken != null && idToken.getToken().getTokenValue().equals(token);
+	}
+
 	private static final class MaxSizeHashMap<K, V> extends LinkedHashMap<K, V> {
 		private final int maxSize;
 

+ 8 - 2
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationService.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -53,6 +53,7 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module;
@@ -112,11 +113,12 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic
 
 	private static final String PK_FILTER = "id = ?";
 	private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorization_code_value = ? OR " +
-			"access_token_value = ? OR refresh_token_value = ?";
+			"access_token_value = ? OR oidc_id_token_value = ? OR refresh_token_value = ?";
 
 	private static final String STATE_FILTER = "state = ?";
 	private static final String AUTHORIZATION_CODE_FILTER = "authorization_code_value = ?";
 	private static final String ACCESS_TOKEN_FILTER = "access_token_value = ?";
+	private static final String ID_TOKEN_FILTER = "oidc_id_token_value = ?";
 	private static final String REFRESH_TOKEN_FILTER = "refresh_token_value = ?";
 
 	// @formatter:off
@@ -240,6 +242,7 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic
 			parameters.add(new SqlParameterValue(Types.VARCHAR, token));
 			parameters.add(mapToSqlParameter("authorization_code_value", token));
 			parameters.add(mapToSqlParameter("access_token_value", token));
+			parameters.add(mapToSqlParameter("oidc_id_token_value", token));
 			parameters.add(mapToSqlParameter("refresh_token_value", token));
 			return findBy(UNKNOWN_TOKEN_TYPE_FILTER, parameters);
 		} else if (OAuth2ParameterNames.STATE.equals(tokenType.getValue())) {
@@ -251,6 +254,9 @@ public class JdbcOAuth2AuthorizationService implements OAuth2AuthorizationServic
 		} else if (OAuth2TokenType.ACCESS_TOKEN.equals(tokenType)) {
 			parameters.add(mapToSqlParameter("access_token_value", token));
 			return findBy(ACCESS_TOKEN_FILTER, parameters);
+		} else if (OidcParameterNames.ID_TOKEN.equals(tokenType.getValue())) {
+			parameters.add(mapToSqlParameter("oidc_id_token_value", token));
+			return findBy(ID_TOKEN_FILTER, parameters);
 		} else if (OAuth2TokenType.REFRESH_TOKEN.equals(tokenType)) {
 			parameters.add(mapToSqlParameter("refresh_token_value", token));
 			return findBy(REFRESH_TOKEN_FILTER, parameters);

+ 43 - 2
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProvider.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -16,8 +16,11 @@
 package org.springframework.security.oauth2.server.authorization.authentication;
 
 import java.security.Principal;
+import java.util.ArrayList;
 import java.util.Collections;
+import java.util.Comparator;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 import org.apache.commons.logging.Log;
@@ -27,6 +30,8 @@ import org.springframework.core.log.LogMessage;
 import org.springframework.security.authentication.AuthenticationProvider;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.session.SessionInformation;
+import org.springframework.security.core.session.SessionRegistry;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClaimAccessor;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
@@ -52,6 +57,7 @@ import org.springframework.security.oauth2.server.authorization.token.DefaultOAu
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
 import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
 import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
 import org.springframework.util.StringUtils;
 
 import static org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthenticationProviderUtils.getAuthenticatedClientElseThrowInvalidClient;
@@ -79,6 +85,7 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth
 	private final Log logger = LogFactory.getLog(getClass());
 	private final OAuth2AuthorizationService authorizationService;
 	private final OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator;
+	private SessionRegistry sessionRegistry;
 
 	/**
 	 * Constructs an {@code OAuth2AuthorizationCodeAuthenticationProvider} using the provided parameters.
@@ -149,10 +156,12 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth
 			this.logger.trace("Validated token request parameters");
 		}
 
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+
 		// @formatter:off
 		DefaultOAuth2TokenContext.Builder tokenContextBuilder = DefaultOAuth2TokenContext.builder()
 				.registeredClient(registeredClient)
-				.principal(authorization.getAttribute(Principal.class.getName()))
+				.principal(principal)
 				.authorizationServerContext(AuthorizationServerContextHolder.getContext())
 				.authorization(authorization)
 				.authorizedScopes(authorization.getAuthorizedScopes())
@@ -210,6 +219,10 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth
 		// ----- ID token -----
 		OidcIdToken idToken;
 		if (authorizationRequest.getScopes().contains(OidcScopes.OPENID)) {
+			SessionInformation sessionInformation = getSessionInformation(principal);
+			if (sessionInformation != null) {
+				tokenContextBuilder.put(SessionInformation.class, sessionInformation);
+			}
 			// @formatter:off
 			tokenContext = tokenContextBuilder
 					.tokenType(ID_TOKEN_TOKEN_TYPE)
@@ -265,4 +278,32 @@ public final class OAuth2AuthorizationCodeAuthenticationProvider implements Auth
 		return OAuth2AuthorizationCodeAuthenticationToken.class.isAssignableFrom(authentication);
 	}
 
+	/**
+	 * Sets the {@link SessionRegistry} used to track OpenID Connect sessions.
+	 *
+	 * @param sessionRegistry the {@link SessionRegistry} used to track OpenID Connect sessions
+	 * @since 1.1.0
+	 */
+	public void setSessionRegistry(SessionRegistry sessionRegistry) {
+		Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+		this.sessionRegistry = sessionRegistry;
+	}
+
+	private SessionInformation getSessionInformation(Authentication principal) {
+		SessionInformation sessionInformation = null;
+		if (this.sessionRegistry != null) {
+			List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), false);
+			if (!CollectionUtils.isEmpty(sessions)) {
+				sessionInformation = sessions.get(0);
+				if (sessions.size() > 1) {
+					// Get the most recent session
+					sessions = new ArrayList<>(sessions);
+					sessions.sort(Comparator.comparing(SessionInformation::getLastRequest));
+					sessionInformation = sessions.get(sessions.size() - 1);
+				}
+			}
+		}
+		return sessionInformation;
+	}
+
 }

+ 7 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepository.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -77,6 +77,7 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor
 			+ "client_authentication_methods, "
 			+ "authorization_grant_types, "
 			+ "redirect_uris, "
+			+ "post_logout_redirect_uris, "
 			+ "scopes, "
 			+ "client_settings,"
 			+ "token_settings";
@@ -90,13 +91,13 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor
 
 	// @formatter:off
 	private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME
-			+ "(" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+			+ "(" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
 	// @formatter:on
 
 	// @formatter:off
 	private static final String UPDATE_REGISTERED_CLIENT_SQL = "UPDATE " + TABLE_NAME
 			+ " SET client_name = ?, client_authentication_methods = ?, authorization_grant_types = ?,"
-			+ " redirect_uris = ?, scopes = ?, client_settings = ?, token_settings = ?"
+			+ " redirect_uris = ?, post_logout_redirect_uris = ?, scopes = ?, client_settings = ?, token_settings = ?"
 			+ " WHERE " + PK_FILTER;
 	// @formatter:on
 
@@ -241,6 +242,7 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor
 			Set<String> clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(rs.getString("client_authentication_methods"));
 			Set<String> authorizationGrantTypes = StringUtils.commaDelimitedListToSet(rs.getString("authorization_grant_types"));
 			Set<String> redirectUris = StringUtils.commaDelimitedListToSet(rs.getString("redirect_uris"));
+			Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(rs.getString("post_logout_redirect_uris"));
 			Set<String> clientScopes = StringUtils.commaDelimitedListToSet(rs.getString("scopes"));
 
 			// @formatter:off
@@ -257,6 +259,7 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor
 							authorizationGrantTypes.forEach(grantType ->
 									grantTypes.add(resolveAuthorizationGrantType(grantType))))
 					.redirectUris((uris) -> uris.addAll(redirectUris))
+					.postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
 					.scopes((scopes) -> scopes.addAll(clientScopes));
 			// @formatter:on
 
@@ -354,6 +357,7 @@ public class JdbcRegisteredClientRepository implements RegisteredClientRepositor
 					new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(clientAuthenticationMethods)),
 					new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(authorizationGrantTypes)),
 					new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(registeredClient.getRedirectUris())),
+					new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(registeredClient.getPostLogoutRedirectUris())),
 					new SqlParameterValue(Types.VARCHAR, StringUtils.collectionToCommaDelimitedString(registeredClient.getScopes())),
 					new SqlParameterValue(Types.VARCHAR, writeMap(registeredClient.getClientSettings().getSettings())),
 					new SqlParameterValue(Types.VARCHAR, writeMap(registeredClient.getTokenSettings().getSettings())));

+ 64 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClient.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -54,6 +54,7 @@ public class RegisteredClient implements Serializable {
 	private Set<ClientAuthenticationMethod> clientAuthenticationMethods;
 	private Set<AuthorizationGrantType> authorizationGrantTypes;
 	private Set<String> redirectUris;
+	private Set<String> postLogoutRedirectUris;
 	private Set<String> scopes;
 	private ClientSettings clientSettings;
 	private TokenSettings tokenSettings;
@@ -145,6 +146,18 @@ public class RegisteredClient implements Serializable {
 		return this.redirectUris;
 	}
 
+	/**
+	 * Returns the post logout redirect URI(s) that the client may use for logout.
+	 * The {@code post_logout_redirect_uri} parameter is used by the client when requesting
+	 * that the End-User's User Agent be redirected to after a logout has been performed.
+	 *
+	 * @return the {@code Set} of post logout redirect URI(s)
+	 * @since 1.1.0
+	 */
+	public Set<String> getPostLogoutRedirectUris() {
+		return this.postLogoutRedirectUris;
+	}
+
 	/**
 	 * Returns the scope(s) that the client may use.
 	 *
@@ -190,6 +203,7 @@ public class RegisteredClient implements Serializable {
 				Objects.equals(this.clientAuthenticationMethods, that.clientAuthenticationMethods) &&
 				Objects.equals(this.authorizationGrantTypes, that.authorizationGrantTypes) &&
 				Objects.equals(this.redirectUris, that.redirectUris) &&
+				Objects.equals(this.postLogoutRedirectUris, that.postLogoutRedirectUris) &&
 				Objects.equals(this.scopes, that.scopes) &&
 				Objects.equals(this.clientSettings, that.clientSettings) &&
 				Objects.equals(this.tokenSettings, that.tokenSettings);
@@ -199,7 +213,7 @@ public class RegisteredClient implements Serializable {
 	public int hashCode() {
 		return Objects.hash(this.id, this.clientId, this.clientIdIssuedAt, this.clientSecret, this.clientSecretExpiresAt,
 				this.clientName, this.clientAuthenticationMethods, this.authorizationGrantTypes, this.redirectUris,
-				this.scopes, this.clientSettings, this.tokenSettings);
+				this.postLogoutRedirectUris, this.scopes, this.clientSettings, this.tokenSettings);
 	}
 
 	@Override
@@ -211,6 +225,7 @@ public class RegisteredClient implements Serializable {
 				", clientAuthenticationMethods=" + this.clientAuthenticationMethods +
 				", authorizationGrantTypes=" + this.authorizationGrantTypes +
 				", redirectUris=" + this.redirectUris +
+				", postLogoutRedirectUris=" + this.postLogoutRedirectUris +
 				", scopes=" + this.scopes +
 				", clientSettings=" + this.clientSettings +
 				", tokenSettings=" + this.tokenSettings +
@@ -253,6 +268,7 @@ public class RegisteredClient implements Serializable {
 		private final Set<ClientAuthenticationMethod> clientAuthenticationMethods = new HashSet<>();
 		private final Set<AuthorizationGrantType> authorizationGrantTypes = new HashSet<>();
 		private final Set<String> redirectUris = new HashSet<>();
+		private final Set<String> postLogoutRedirectUris = new HashSet<>();
 		private final Set<String> scopes = new HashSet<>();
 		private ClientSettings clientSettings;
 		private TokenSettings tokenSettings;
@@ -277,6 +293,9 @@ public class RegisteredClient implements Serializable {
 			if (!CollectionUtils.isEmpty(registeredClient.getRedirectUris())) {
 				this.redirectUris.addAll(registeredClient.getRedirectUris());
 			}
+			if (!CollectionUtils.isEmpty(registeredClient.getPostLogoutRedirectUris())) {
+				this.postLogoutRedirectUris.addAll(registeredClient.getPostLogoutRedirectUris());
+			}
 			if (!CollectionUtils.isEmpty(registeredClient.getScopes())) {
 				this.scopes.addAll(registeredClient.getScopes());
 			}
@@ -421,6 +440,33 @@ public class RegisteredClient implements Serializable {
 			return this;
 		}
 
+		/**
+		 * Adds a post logout redirect URI the client may use for logout.
+		 * The {@code post_logout_redirect_uri} parameter is used by the client when requesting
+		 * that the End-User's User Agent be redirected to after a logout has been performed.
+		 *
+		 * @param postLogoutRedirectUri the post logout redirect URI
+		 * @return the {@link Builder}
+		 * @since 1.1.0
+		 */
+		public Builder postLogoutRedirectUri(String postLogoutRedirectUri) {
+			this.postLogoutRedirectUris.add(postLogoutRedirectUri);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the post logout redirect URI(s)
+		 * allowing the ability to add, replace, or remove.
+		 *
+		 * @param postLogoutRedirectUrisConsumer a {@link Consumer} of the post logout redirect URI(s)
+		 * @return the {@link Builder}
+		 * @since 1.1.0
+		 */
+		public Builder postLogoutRedirectUris(Consumer<Set<String>> postLogoutRedirectUrisConsumer) {
+			postLogoutRedirectUrisConsumer.accept(this.postLogoutRedirectUris);
+			return this;
+		}
+
 		/**
 		 * Adds a scope the client may use.
 		 *
@@ -499,6 +545,7 @@ public class RegisteredClient implements Serializable {
 			}
 			validateScopes();
 			validateRedirectUris();
+			validatePostLogoutRedirectUris();
 			return create();
 		}
 
@@ -523,6 +570,8 @@ public class RegisteredClient implements Serializable {
 					new HashSet<>(this.authorizationGrantTypes));
 			registeredClient.redirectUris = Collections.unmodifiableSet(
 					new HashSet<>(this.redirectUris));
+			registeredClient.postLogoutRedirectUris = Collections.unmodifiableSet(
+					new HashSet<>(this.postLogoutRedirectUris));
 			registeredClient.scopes = Collections.unmodifiableSet(
 					new HashSet<>(this.scopes));
 			registeredClient.clientSettings = this.clientSettings;
@@ -557,12 +606,23 @@ public class RegisteredClient implements Serializable {
 				return;
 			}
 
-			for (String redirectUri : redirectUris) {
+			for (String redirectUri : this.redirectUris) {
 				Assert.isTrue(validateRedirectUri(redirectUri),
 						"redirect_uri \"" + redirectUri + "\" is not a valid redirect URI or contains fragment");
 			}
 		}
 
+		private void validatePostLogoutRedirectUris() {
+			if (CollectionUtils.isEmpty(this.postLogoutRedirectUris)) {
+				return;
+			}
+
+			for (String postLogoutRedirectUri : this.postLogoutRedirectUris) {
+				Assert.isTrue(validateRedirectUri(postLogoutRedirectUri),
+						"post_logout_redirect_uri \"" + postLogoutRedirectUri + "\" is not a valid post logout redirect URI or contains fragment");
+			}
+		}
+
 		private static boolean validateRedirectUri(String redirectUri) {
 			try {
 				URI validRedirectUri = new URI(redirectUri);
@@ -571,5 +631,6 @@ public class RegisteredClient implements Serializable {
 				return false;
 			}
 		}
+
 	}
 }

+ 10 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationEndpointConfigurer.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -44,6 +44,7 @@ import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
 import org.springframework.security.web.authentication.preauth.AbstractPreAuthenticatedProcessingFilter;
+import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
 import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
 import org.springframework.security.web.util.matcher.OrRequestMatcher;
 import org.springframework.security.web.util.matcher.RequestMatcher;
@@ -68,6 +69,7 @@ public final class OAuth2AuthorizationEndpointConfigurer extends AbstractOAuth2C
 	private AuthenticationFailureHandler errorResponseHandler;
 	private String consentPage;
 	private Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authorizationCodeRequestAuthenticationValidator;
+	private SessionAuthenticationStrategy sessionAuthenticationStrategy;
 
 	/**
 	 * Restrict for internal use only.
@@ -200,6 +202,10 @@ public final class OAuth2AuthorizationEndpointConfigurer extends AbstractOAuth2C
 						this.authorizationCodeRequestAuthenticationValidator.andThen(authenticationValidator);
 	}
 
+	void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionAuthenticationStrategy) {
+		this.sessionAuthenticationStrategy = sessionAuthenticationStrategy;
+	}
+
 	@Override
 	void init(HttpSecurity httpSecurity) {
 		AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity);
@@ -245,6 +251,9 @@ public final class OAuth2AuthorizationEndpointConfigurer extends AbstractOAuth2C
 		if (StringUtils.hasText(this.consentPage)) {
 			authorizationEndpointFilter.setConsentPage(this.consentPage);
 		}
+		if (this.sessionAuthenticationStrategy != null) {
+			authorizationEndpointFilter.setSessionAuthenticationStrategy(this.sessionAuthenticationStrategy);
+		}
 		httpSecurity.addFilterBefore(postProcess(authorizationEndpointFilter), AbstractPreAuthenticatedProcessingFilter.class);
 	}
 

+ 24 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerConfigurer.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -29,6 +29,8 @@ import org.springframework.security.config.Customizer;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
 import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.session.SessionRegistry;
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
 import org.springframework.security.oauth2.core.OAuth2Token;
@@ -240,8 +242,23 @@ public final class OAuth2AuthorizationServerConfigurer
 		AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity);
 		validateAuthorizationServerSettings(authorizationServerSettings);
 
-		OidcConfigurer oidcConfigurer = getConfigurer(OidcConfigurer.class);
-		if (oidcConfigurer == null) {
+		if (isOidcEnabled()) {
+			// Add OpenID Connect session tracking capabilities.
+			SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity);
+			OAuth2AuthorizationEndpointConfigurer authorizationEndpointConfigurer =
+					getConfigurer(OAuth2AuthorizationEndpointConfigurer.class);
+			authorizationEndpointConfigurer.setSessionAuthenticationStrategy((authentication, request, response) -> {
+				if (authentication instanceof OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication) {
+					if (authorizationCodeRequestAuthentication.getScopes().contains(OidcScopes.OPENID)) {
+						if (sessionRegistry.getSessionInformation(request.getSession().getId()) == null) {
+							sessionRegistry.registerNewSession(
+									request.getSession().getId(),
+									((Authentication) authorizationCodeRequestAuthentication.getPrincipal()).getPrincipal());
+						}
+					}
+				}
+			});
+		} else {
 			// OpenID Connect is disabled.
 			// Add an authentication validator that rejects authentication requests.
 			OAuth2AuthorizationEndpointConfigurer authorizationEndpointConfigurer =
@@ -297,6 +314,10 @@ public final class OAuth2AuthorizationServerConfigurer
 		}
 	}
 
+	private boolean isOidcEnabled() {
+		return getConfigurer(OidcConfigurer.class) != null;
+	}
+
 	private Map<Class<? extends AbstractOAuth2Configurer>, AbstractOAuth2Configurer> createConfigurers() {
 		Map<Class<? extends AbstractOAuth2Configurer>, AbstractOAuth2Configurer> configurers = new LinkedHashMap<>();
 		configurers.put(OAuth2ClientAuthenticationConfigurer.class, new OAuth2ClientAuthenticationConfigurer(this::postProcess));

+ 29 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ConfigurerUtils.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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,8 +24,14 @@ import org.springframework.beans.factory.BeanFactoryUtils;
 import org.springframework.beans.factory.NoSuchBeanDefinitionException;
 import org.springframework.beans.factory.NoUniqueBeanDefinitionException;
 import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationListener;
+import org.springframework.context.event.GenericApplicationListenerAdapter;
+import org.springframework.context.event.SmartApplicationListener;
 import org.springframework.core.ResolvableType;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.context.DelegatingApplicationListener;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.core.session.SessionRegistryImpl;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.jwt.JwtEncoder;
 import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@@ -180,6 +186,28 @@ final class OAuth2ConfigurerUtils {
 		return authorizationServerSettings;
 	}
 
+	static SessionRegistry getSessionRegistry(HttpSecurity httpSecurity) {
+		SessionRegistry sessionRegistry = httpSecurity.getSharedObject(SessionRegistry.class);
+		if (sessionRegistry == null) {
+			sessionRegistry = getOptionalBean(httpSecurity, SessionRegistry.class);
+			if (sessionRegistry == null) {
+				sessionRegistry = new SessionRegistryImpl();
+				registerDelegateApplicationListener(httpSecurity, (SessionRegistryImpl) sessionRegistry);
+			}
+			httpSecurity.setSharedObject(SessionRegistry.class, sessionRegistry);
+		}
+		return sessionRegistry;
+	}
+
+	private static void registerDelegateApplicationListener(HttpSecurity httpSecurity, ApplicationListener<?> delegate) {
+		DelegatingApplicationListener delegatingApplicationListener = getOptionalBean(httpSecurity, DelegatingApplicationListener.class);
+		if (delegatingApplicationListener == null) {
+			return;
+		}
+		SmartApplicationListener smartListener = new GenericApplicationListenerAdapter(delegate);
+		delegatingApplicationListener.addListener(smartListener);
+	}
+
 	static <T> T getBean(HttpSecurity httpSecurity, Class<T> type) {
 		return httpSecurity.getSharedObject(ApplicationContext.class).getBean(type);
 	}

+ 3 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenEndpointConfigurer.java

@@ -26,6 +26,7 @@ import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.AuthenticationProvider;
 import org.springframework.security.config.annotation.ObjectPostProcessor;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.core.session.SessionRegistry;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.OAuth2Token;
@@ -216,9 +217,11 @@ public final class OAuth2TokenEndpointConfigurer extends AbstractOAuth2Configure
 
 		OAuth2AuthorizationService authorizationService = OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity);
 		OAuth2TokenGenerator<? extends OAuth2Token> tokenGenerator = OAuth2ConfigurerUtils.getTokenGenerator(httpSecurity);
+		SessionRegistry sessionRegistry = OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity);
 
 		OAuth2AuthorizationCodeAuthenticationProvider authorizationCodeAuthenticationProvider =
 				new OAuth2AuthorizationCodeAuthenticationProvider(authorizationService, tokenGenerator);
+		authorizationCodeAuthenticationProvider.setSessionRegistry(sessionRegistry);
 		authenticationProviders.add(authorizationCodeAuthenticationProvider);
 
 		OAuth2RefreshTokenAuthenticationProvider refreshTokenAuthenticationProvider =

+ 15 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcConfigurer.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -37,6 +37,7 @@ import org.springframework.web.util.UriComponentsBuilder;
  * @since 0.2.0
  * @see OAuth2AuthorizationServerConfigurer#oidc
  * @see OidcProviderConfigurationEndpointConfigurer
+ * @see OidcLogoutEndpointConfigurer
  * @see OidcClientRegistrationEndpointConfigurer
  * @see OidcUserInfoEndpointConfigurer
  */
@@ -50,6 +51,7 @@ public final class OidcConfigurer extends AbstractOAuth2Configurer {
 	OidcConfigurer(ObjectPostProcessor<Object> objectPostProcessor) {
 		super(objectPostProcessor);
 		addConfigurer(OidcProviderConfigurationEndpointConfigurer.class, new OidcProviderConfigurationEndpointConfigurer(objectPostProcessor));
+		addConfigurer(OidcLogoutEndpointConfigurer.class, new OidcLogoutEndpointConfigurer(objectPostProcessor));
 		addConfigurer(OidcUserInfoEndpointConfigurer.class, new OidcUserInfoEndpointConfigurer(objectPostProcessor));
 	}
 
@@ -65,6 +67,18 @@ public final class OidcConfigurer extends AbstractOAuth2Configurer {
 		return this;
 	}
 
+	/**
+	 * Configures the OpenID Connect 1.0 RP-Initiated Logout Endpoint.
+	 *
+	 * @param logoutEndpointCustomizer the {@link Customizer} providing access to the {@link OidcLogoutEndpointConfigurer}
+	 * @return the {@link OidcConfigurer} for further configuration
+	 * @since 1.1.0
+	 */
+	public OidcConfigurer logoutEndpoint(Customizer<OidcLogoutEndpointConfigurer> logoutEndpointCustomizer) {
+		logoutEndpointCustomizer.customize(getConfigurer(OidcLogoutEndpointConfigurer.class));
+		return this;
+	}
+
 	/**
 	 * Configures the OpenID Connect Dynamic Client Registration 1.0 Endpoint.
 	 *

+ 219 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcLogoutEndpointConfigurer.java

@@ -0,0 +1,219 @@
+/*
+ * Copyright 2020-2023 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.config.annotation.web.configurers;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Consumer;
+
+import jakarta.servlet.http.HttpServletRequest;
+
+import org.springframework.http.HttpMethod;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.config.annotation.ObjectPostProcessor;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.oidc.web.OidcLogoutEndpointFilter;
+import org.springframework.security.oauth2.server.authorization.oidc.web.authentication.OidcLogoutAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.web.authentication.DelegatingAuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.logout.LogoutFilter;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.OrRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+
+/**
+ * Configurer for OpenID Connect 1.0 RP-Initiated Logout Endpoint.
+ *
+ * @author Joe Grandja
+ * @since 1.1.0
+ * @see OidcConfigurer#logoutEndpoint
+ * @see OidcLogoutEndpointFilter
+ */
+public final class OidcLogoutEndpointConfigurer extends AbstractOAuth2Configurer {
+	private RequestMatcher requestMatcher;
+	private final List<AuthenticationConverter> logoutRequestConverters = new ArrayList<>();
+	private Consumer<List<AuthenticationConverter>> logoutRequestConvertersConsumer = (logoutRequestConverters) -> {};
+	private final List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
+	private Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer = (authenticationProviders) -> {};
+	private AuthenticationSuccessHandler logoutResponseHandler;
+	private AuthenticationFailureHandler errorResponseHandler;
+
+	/**
+	 * Restrict for internal use only.
+	 */
+	OidcLogoutEndpointConfigurer(ObjectPostProcessor<Object> objectPostProcessor) {
+		super(objectPostProcessor);
+	}
+
+	/**
+	 * Adds an {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest}
+	 * to an instance of {@link OidcLogoutAuthenticationToken} used for authenticating the request.
+	 *
+	 * @param logoutRequestConverter an {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest}
+	 * @return the {@link OidcLogoutEndpointConfigurer} for further configuration
+	 */
+	public OidcLogoutEndpointConfigurer logoutRequestConverter(
+			AuthenticationConverter logoutRequestConverter) {
+		Assert.notNull(logoutRequestConverter, "logoutRequestConverter cannot be null");
+		this.logoutRequestConverters.add(logoutRequestConverter);
+		return this;
+	}
+
+	/**
+	 * Sets the {@code Consumer} providing access to the {@code List} of default
+	 * and (optionally) added {@link #logoutRequestConverter(AuthenticationConverter) AuthenticationConverter}'s
+	 * allowing the ability to add, remove, or customize a specific {@link AuthenticationConverter}.
+	 *
+	 * @param logoutRequestConvertersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationConverter}'s
+	 * @return the {@link OidcLogoutEndpointConfigurer} for further configuration
+	 */
+	public OidcLogoutEndpointConfigurer logoutRequestConverters(
+			Consumer<List<AuthenticationConverter>> logoutRequestConvertersConsumer) {
+		Assert.notNull(logoutRequestConvertersConsumer, "logoutRequestConvertersConsumer cannot be null");
+		this.logoutRequestConvertersConsumer = logoutRequestConvertersConsumer;
+		return this;
+	}
+
+	/**
+	 * Adds an {@link AuthenticationProvider} used for authenticating an {@link OidcLogoutAuthenticationToken}.
+	 *
+	 * @param authenticationProvider an {@link AuthenticationProvider} used for authenticating an {@link OidcLogoutAuthenticationToken}
+	 * @return the {@link OidcLogoutEndpointConfigurer} for further configuration
+	 */
+	public OidcLogoutEndpointConfigurer authenticationProvider(AuthenticationProvider authenticationProvider) {
+		Assert.notNull(authenticationProvider, "authenticationProvider cannot be null");
+		this.authenticationProviders.add(authenticationProvider);
+		return this;
+	}
+
+	/**
+	 * Sets the {@code Consumer} providing access to the {@code List} of default
+	 * and (optionally) added {@link #authenticationProvider(AuthenticationProvider) AuthenticationProvider}'s
+	 * allowing the ability to add, remove, or customize a specific {@link AuthenticationProvider}.
+	 *
+	 * @param authenticationProvidersConsumer the {@code Consumer} providing access to the {@code List} of default and (optionally) added {@link AuthenticationProvider}'s
+	 * @return the {@link OidcLogoutEndpointConfigurer} for further configuration
+	 */
+	public OidcLogoutEndpointConfigurer authenticationProviders(
+			Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer) {
+		Assert.notNull(authenticationProvidersConsumer, "authenticationProvidersConsumer cannot be null");
+		this.authenticationProvidersConsumer = authenticationProvidersConsumer;
+		return this;
+	}
+
+	/**
+	 * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken}
+	 * and performing the logout.
+	 *
+	 * @param logoutResponseHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken}
+	 * @return the {@link OidcLogoutEndpointConfigurer} for further configuration
+	 */
+	public OidcLogoutEndpointConfigurer logoutResponseHandler(AuthenticationSuccessHandler logoutResponseHandler) {
+		this.logoutResponseHandler = logoutResponseHandler;
+		return this;
+	}
+
+	/**
+	 * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException}
+	 * and returning the {@link OAuth2Error Error Response}.
+	 *
+	 * @param errorResponseHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException}
+	 * @return the {@link OidcLogoutEndpointConfigurer} for further configuration
+	 */
+	public OidcLogoutEndpointConfigurer errorResponseHandler(AuthenticationFailureHandler errorResponseHandler) {
+		this.errorResponseHandler = errorResponseHandler;
+		return this;
+	}
+
+	@Override
+	void init(HttpSecurity httpSecurity) {
+		AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity);
+		String logoutEndpointUri = authorizationServerSettings.getOidcLogoutEndpoint();
+		this.requestMatcher = new OrRequestMatcher(
+				new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.GET.name()),
+				new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.POST.name())
+		);
+
+		List<AuthenticationProvider> authenticationProviders = createDefaultAuthenticationProviders(httpSecurity);
+		if (!this.authenticationProviders.isEmpty()) {
+			authenticationProviders.addAll(0, this.authenticationProviders);
+		}
+		this.authenticationProvidersConsumer.accept(authenticationProviders);
+		authenticationProviders.forEach(authenticationProvider ->
+				httpSecurity.authenticationProvider(postProcess(authenticationProvider)));
+	}
+
+	@Override
+	void configure(HttpSecurity httpSecurity) {
+		AuthenticationManager authenticationManager = httpSecurity.getSharedObject(AuthenticationManager.class);
+		AuthorizationServerSettings authorizationServerSettings = OAuth2ConfigurerUtils.getAuthorizationServerSettings(httpSecurity);
+
+		OidcLogoutEndpointFilter oidcLogoutEndpointFilter =
+				new OidcLogoutEndpointFilter(
+						authenticationManager,
+						authorizationServerSettings.getOidcLogoutEndpoint());
+		List<AuthenticationConverter> authenticationConverters = createDefaultAuthenticationConverters();
+		if (!this.logoutRequestConverters.isEmpty()) {
+			authenticationConverters.addAll(0, this.logoutRequestConverters);
+		}
+		this.logoutRequestConvertersConsumer.accept(authenticationConverters);
+		oidcLogoutEndpointFilter.setAuthenticationConverter(
+				new DelegatingAuthenticationConverter(authenticationConverters));
+		if (this.logoutResponseHandler != null) {
+			oidcLogoutEndpointFilter.setAuthenticationSuccessHandler(this.logoutResponseHandler);
+		}
+		if (this.errorResponseHandler != null) {
+			oidcLogoutEndpointFilter.setAuthenticationFailureHandler(this.errorResponseHandler);
+		}
+		httpSecurity.addFilterBefore(postProcess(oidcLogoutEndpointFilter), LogoutFilter.class);
+	}
+
+	@Override
+	RequestMatcher getRequestMatcher() {
+		return this.requestMatcher;
+	}
+
+	private static List<AuthenticationConverter> createDefaultAuthenticationConverters() {
+		List<AuthenticationConverter> authenticationConverters = new ArrayList<>();
+
+		authenticationConverters.add(new OidcLogoutAuthenticationConverter());
+
+		return authenticationConverters;
+	}
+
+	private static List<AuthenticationProvider> createDefaultAuthenticationProviders(HttpSecurity httpSecurity) {
+		List<AuthenticationProvider> authenticationProviders = new ArrayList<>();
+
+		OidcLogoutAuthenticationProvider oidcLogoutAuthenticationProvider =
+				new OidcLogoutAuthenticationProvider(
+						OAuth2ConfigurerUtils.getRegisteredClientRepository(httpSecurity),
+						OAuth2ConfigurerUtils.getAuthorizationService(httpSecurity),
+						OAuth2ConfigurerUtils.getSessionRegistry(httpSecurity));
+		authenticationProviders.add(oidcLogoutAuthenticationProvider);
+
+		return authenticationProviders;
+	}
+
+}

+ 14 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimAccessor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -37,6 +37,7 @@ import org.springframework.security.oauth2.jwt.Jwt;
  * @see OidcClientMetadataClaimNames
  * @see OidcClientRegistration
  * @see <a target="_blank" href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata">2. Client Metadata</a>
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata">3.1. Client Registration Metadata</a>
  */
 public interface OidcClientMetadataClaimAccessor extends ClaimAccessor {
 
@@ -94,6 +95,18 @@ public interface OidcClientMetadataClaimAccessor extends ClaimAccessor {
 		return getClaimAsStringList(OidcClientMetadataClaimNames.REDIRECT_URIS);
 	}
 
+	/**
+	 * Returns the post logout redirection {@code URI} values used by the Client {@code (post_logout_redirect_uris)}.
+	 * The {@code post_logout_redirect_uri} parameter is used by the client when requesting
+	 * that the End-User's User Agent be redirected to after a logout has been performed.
+	 *
+	 * @return the post logout redirection {@code URI} values used by the Client
+	 * @since 1.1.0
+	 */
+	default List<String> getPostLogoutRedirectUris() {
+		return getClaimAsStringList(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS);
+	}
+
 	/**
 	 * Returns the authentication method used by the Client for the Token Endpoint {@code (token_endpoint_auth_method)}.
 	 *

+ 10 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientMetadataClaimNames.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -28,6 +28,7 @@ import org.springframework.security.oauth2.jwt.Jwt;
  * @author Joe Grandja
  * @since 0.1.1
  * @see <a target="_blank" href="https://openid.net/specs/openid-connect-registration-1_0.html#ClientMetadata">2. Client Metadata</a>
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata">3.1. Client Registration Metadata</a>
  */
 public final class OidcClientMetadataClaimNames {
 
@@ -61,6 +62,14 @@ public final class OidcClientMetadataClaimNames {
 	 */
 	public static final String REDIRECT_URIS = "redirect_uris";
 
+	/**
+	 * {@code post_logout_redirect_uris} - the post logout redirection {@code URI} values used by the Client.
+	 * The {@code post_logout_redirect_uri} parameter is used by the client when requesting
+	 * that the End-User's User Agent be redirected to after a logout has been performed.
+	 * @since 1.1.0
+	 */
+	public static final String POST_LOGOUT_REDIRECT_URIS = "post_logout_redirect_uris";
+
 	/**
 	 * {@code token_endpoint_auth_method} - the authentication method used by the Client for the Token Endpoint
 	 */

+ 35 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistration.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -44,8 +44,9 @@ import org.springframework.util.Assert;
  * @author Joe Grandja
  * @since 0.1.1
  * @see OidcClientMetadataClaimAccessor
- * @see <a href="https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest">3.1. Client Registration Request</a>
- * @see <a href="https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse">3.2. Client Registration Response</a>
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationRequest">3.1. Client Registration Request</a>
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-registration-1_0.html#RegistrationResponse">3.2. Client Registration Response</a>
+ * @see <a target="_blank" href="https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ClientMetadata">3.1. Client Registration Metadata</a>
  */
 public final class OidcClientRegistration implements OidcClientMetadataClaimAccessor, Serializable {
 	private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
@@ -168,6 +169,33 @@ public final class OidcClientRegistration implements OidcClientMetadataClaimAcce
 			return this;
 		}
 
+		/**
+		 * Add the post logout redirection {@code URI} used by the Client, OPTIONAL.
+		 * The {@code post_logout_redirect_uri} parameter is used by the client when requesting
+		 * that the End-User's User Agent be redirected to after a logout has been performed.
+		 *
+		 * @param postLogoutRedirectUri the post logout redirection {@code URI} used by the Client
+		 * @return the {@link Builder} for further configuration
+		 * @since 1.1.0
+		 */
+		public Builder postLogoutRedirectUri(String postLogoutRedirectUri) {
+			addClaimToClaimList(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUri);
+			return this;
+		}
+
+		/**
+		 * A {@code Consumer} of the post logout redirection {@code URI} values used by the Client,
+		 * allowing the ability to add, replace, or remove, OPTIONAL.
+		 *
+		 * @param postLogoutRedirectUrisConsumer a {@code Consumer} of the post logout redirection {@code URI} values used by the Client
+		 * @return the {@link Builder} for further configuration
+		 * @since 1.1.0
+		 */
+		public Builder postLogoutRedirectUris(Consumer<List<String>> postLogoutRedirectUrisConsumer) {
+			acceptClaimValues(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, postLogoutRedirectUrisConsumer);
+			return this;
+		}
+
 		/**
 		 * Sets the authentication method used by the Client for the Token Endpoint, OPTIONAL.
 		 *
@@ -358,6 +386,10 @@ public final class OidcClientRegistration implements OidcClientMetadataClaimAcce
 			Assert.notNull(this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris cannot be null");
 			Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris must be of type List");
 			Assert.notEmpty((List<?>) this.claims.get(OidcClientMetadataClaimNames.REDIRECT_URIS), "redirect_uris cannot be empty");
+			if (this.claims.get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS) != null) {
+				Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS), "post_logout_redirect_uris must be of type List");
+				Assert.notEmpty((List<?>) this.claims.get(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS), "post_logout_redirect_uris cannot be empty");
+			}
 			if (this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES) != null) {
 				Assert.isInstanceOf(List.class, this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES), "grant_types must be of type List");
 				Assert.notEmpty((List<?>) this.claims.get(OidcClientMetadataClaimNames.GRANT_TYPES), "grant_types cannot be empty");

+ 16 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfiguration.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -32,6 +32,7 @@ import org.springframework.util.Assert;
  * The claims are defined by the OpenID Connect Discovery 1.0 specification.
  *
  * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
  * @since 0.1.0
  * @see AbstractOAuth2AuthorizationServerMetadata
  * @see OidcProviderMetadataClaimAccessor
@@ -130,6 +131,17 @@ public final class OidcProviderConfiguration extends AbstractOAuth2Authorization
 			return claim(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, userInfoEndpoint);
 		}
 
+		/**
+		 * Use this {@code end_session_endpoint} in the resulting {@link OidcProviderConfiguration}, OPTIONAL.
+		 *
+		 * @param endSessionEndpoint the {@code URL} of the OpenID Connect 1.0 End Session Endpoint
+		 * @return the {@link Builder} for further configuration
+		 * @since 1.1.0
+		 */
+		public Builder endSessionEndpoint(String endSessionEndpoint) {
+			return claim(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, endSessionEndpoint);
+		}
+
 		/**
 		 * Validate the claims and build the {@link OidcProviderConfiguration}.
 		 * <p>
@@ -159,6 +171,9 @@ public final class OidcProviderConfiguration extends AbstractOAuth2Authorization
 			if (getClaims().get(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT) != null) {
 				validateURL(getClaims().get(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT), "userInfoEndpoint must be a valid URL");
 			}
+			if (getClaims().get(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT) != null) {
+				validateURL(getClaims().get(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT), "endSessionEndpoint must be a valid URL");
+			}
 		}
 
 		@SuppressWarnings("unchecked")

+ 12 - 2
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimAccessor.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -15,7 +15,6 @@
  */
 package org.springframework.security.oauth2.server.authorization.oidc;
 
-
 import java.net.URL;
 import java.util.List;
 
@@ -30,6 +29,7 @@ import org.springframework.security.oauth2.server.authorization.OAuth2Authorizat
  * in the OpenID Provider Configuration Response.
  *
  * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
  * @since 0.1.0
  * @see ClaimAccessor
  * @see OAuth2AuthorizationServerMetadataClaimAccessor
@@ -68,4 +68,14 @@ public interface OidcProviderMetadataClaimAccessor extends OAuth2AuthorizationSe
 		return getClaimAsURL(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT);
 	}
 
+	/**
+	 * Returns the {@code URL} of the OpenID Connect 1.0 End Session Endpoint {@code (end_session_endpoint)}.
+	 *
+	 * @return the {@code URL} of the OpenID Connect 1.0 End Session Endpoint
+	 * @since 1.1.0
+	 */
+	default URL getEndSessionEndpoint() {
+		return getClaimAsURL(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT);
+	}
+
 }

+ 8 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderMetadataClaimNames.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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 org.springframework.security.oauth2.server.authorization.OAuth2Authorizat
  * in the OpenID Provider Configuration Response.
  *
  * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
  * @since 0.1.0
  * @see OAuth2AuthorizationServerMetadataClaimNames
  * @see <a target="_blank" href="https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata">3. OpenID Provider Metadata</a>
@@ -46,6 +47,12 @@ public final class OidcProviderMetadataClaimNames extends OAuth2AuthorizationSer
 	 */
 	public static final String USER_INFO_ENDPOINT = "userinfo_endpoint";
 
+	/**
+	 * {@code end_session_endpoint} - the {@code URL} of the OpenID Connect 1.0 End Session Endpoint
+	 * @since 1.1.0
+	 */
+	public static final String END_SESSION_ENDPOINT = "end_session_endpoint";
+
 	private OidcProviderMetadataClaimNames() {
 	}
 

+ 10 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProvider.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -174,6 +174,10 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe
 			throwInvalidClientRegistration(OAuth2ErrorCodes.INVALID_REDIRECT_URI, OidcClientMetadataClaimNames.REDIRECT_URIS);
 		}
 
+		if (!isValidRedirectUris(clientRegistrationAuthentication.getClientRegistration().getPostLogoutRedirectUris())) {
+			throwInvalidClientRegistration("invalid_client_metadata", OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS);
+		}
+
 		if (!isValidTokenEndpointAuthenticationMethod(clientRegistrationAuthentication.getClientRegistration())) {
 			throwInvalidClientRegistration("invalid_client_metadata", OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
 		}
@@ -371,6 +375,11 @@ public final class OidcClientRegistrationAuthenticationProvider implements Authe
 			builder.redirectUris(redirectUris ->
 					redirectUris.addAll(clientRegistration.getRedirectUris()));
 
+			if (!CollectionUtils.isEmpty(clientRegistration.getPostLogoutRedirectUris())) {
+				builder.postLogoutRedirectUris(postLogoutRedirectUris ->
+						postLogoutRedirectUris.addAll(clientRegistration.getPostLogoutRedirectUris()));
+			}
+
 			if (!CollectionUtils.isEmpty(clientRegistration.getGrantTypes())) {
 				builder.authorizationGrantTypes(authorizationGrantTypes ->
 						clientRegistration.getGrantTypes().forEach(grantType ->

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

@@ -0,0 +1,181 @@
+/*
+ * Copyright 2020-2023 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.List;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.session.SessionInformation;
+import org.springframework.security.core.session.SessionRegistry;
+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.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.util.Assert;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+/**
+ * An {@link AuthenticationProvider} implementation for OpenID Connect 1.0 RP-Initiated Logout Endpoint.
+ *
+ * @author Joe Grandja
+ * @since 1.1.0
+ * @see RegisteredClientRepository
+ * @see OAuth2AuthorizationService
+ * @see SessionRegistry
+ * @see <a href="https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout">2. RP-Initiated Logout</a>
+ */
+public final class OidcLogoutAuthenticationProvider implements AuthenticationProvider {
+	private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE =
+			new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
+	private final Log logger = LogFactory.getLog(getClass());
+	private final RegisteredClientRepository registeredClientRepository;
+	private final OAuth2AuthorizationService authorizationService;
+	private final SessionRegistry sessionRegistry;
+
+	/**
+	 * Constructs an {@code OidcLogoutAuthenticationProvider} using the provided parameters.
+	 *
+	 * @param registeredClientRepository the repository of registered clients
+	 * @param authorizationService the authorization service
+	 * @param sessionRegistry the {@link SessionRegistry} used to track OpenID Connect sessions
+	 */
+	public OidcLogoutAuthenticationProvider(RegisteredClientRepository registeredClientRepository,
+			OAuth2AuthorizationService authorizationService, SessionRegistry sessionRegistry) {
+		Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
+		Assert.notNull(authorizationService, "authorizationService cannot be null");
+		Assert.notNull(sessionRegistry, "sessionRegistry cannot be null");
+		this.registeredClientRepository = registeredClientRepository;
+		this.authorizationService = authorizationService;
+		this.sessionRegistry = sessionRegistry;
+	}
+
+	@Override
+	public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+		OidcLogoutAuthenticationToken oidcLogoutAuthentication =
+				(OidcLogoutAuthenticationToken) authentication;
+
+		OAuth2Authorization authorization = this.authorizationService.findByToken(
+				oidcLogoutAuthentication.getIdToken(), ID_TOKEN_TOKEN_TYPE);
+		if (authorization == null) {
+			throwError(OAuth2ErrorCodes.INVALID_TOKEN, "id_token_hint");
+		}
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Retrieved authorization with ID Token");
+		}
+
+		RegisteredClient registeredClient = this.registeredClientRepository.findById(
+				authorization.getRegisteredClientId());
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Retrieved registered client");
+		}
+
+		OidcIdToken idToken = authorization.getToken(OidcIdToken.class).getToken();
+
+		// Validate client identity
+		List<String> audClaim = idToken.getAudience();
+		if (CollectionUtils.isEmpty(audClaim) ||
+				!audClaim.contains(registeredClient.getClientId())) {
+			throwError(OAuth2ErrorCodes.INVALID_TOKEN, IdTokenClaimNames.AUD);
+		}
+		if (StringUtils.hasText(oidcLogoutAuthentication.getClientId()) &&
+				!oidcLogoutAuthentication.getClientId().equals(registeredClient.getClientId())) {
+			throwError(OAuth2ErrorCodes.INVALID_TOKEN, OAuth2ParameterNames.CLIENT_ID);
+		}
+		if (StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri()) &&
+				!registeredClient.getPostLogoutRedirectUris().contains(oidcLogoutAuthentication.getPostLogoutRedirectUri())) {
+			throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri");
+		}
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Validated logout request parameters");
+		}
+
+		// Validate user session
+		SessionInformation sessionInformation = null;
+		Authentication userPrincipal = (Authentication) oidcLogoutAuthentication.getPrincipal();
+		if (isPrincipalAuthenticated(userPrincipal) &&
+				StringUtils.hasText(oidcLogoutAuthentication.getSessionId())) {
+			sessionInformation = findSessionInformation(
+					userPrincipal, oidcLogoutAuthentication.getSessionId());
+			if (sessionInformation != null) {
+				String sidClaim = idToken.getClaim("sid");
+				if (!StringUtils.hasText(sidClaim) ||
+						!sidClaim.equals(sessionInformation.getSessionId())) {
+					throwError(OAuth2ErrorCodes.INVALID_TOKEN, "sid");
+				}
+			}
+		}
+
+		if (this.logger.isTraceEnabled()) {
+			this.logger.trace("Authenticated logout request");
+		}
+
+		return new OidcLogoutAuthenticationToken(oidcLogoutAuthentication.getIdToken(), userPrincipal,
+				sessionInformation, oidcLogoutAuthentication.getClientId(),
+				oidcLogoutAuthentication.getPostLogoutRedirectUri(), oidcLogoutAuthentication.getState());
+	}
+
+	@Override
+	public boolean supports(Class<?> authentication) {
+		return OidcLogoutAuthenticationToken.class.isAssignableFrom(authentication);
+	}
+
+	private static boolean isPrincipalAuthenticated(Authentication principal) {
+		return principal != null &&
+				!AnonymousAuthenticationToken.class.isAssignableFrom(principal.getClass()) &&
+				principal.isAuthenticated();
+	}
+
+	private SessionInformation findSessionInformation(Authentication principal, String sessionId) {
+		List<SessionInformation> sessions = this.sessionRegistry.getAllSessions(principal.getPrincipal(), true);
+		SessionInformation sessionInformation = null;
+		if (!CollectionUtils.isEmpty(sessions)) {
+			for (SessionInformation session : sessions) {
+				if (session.getSessionId().equals(sessionId)) {
+					sessionInformation = session;
+					break;
+				}
+			}
+		}
+		return sessionInformation;
+	}
+
+	private static void throwError(String errorCode, String parameterName) {
+		OAuth2Error error = new OAuth2Error(
+				errorCode,
+				"OpenID Connect 1.0 Logout Request Parameter: " + parameterName,
+				"https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling");
+		throw new OAuth2AuthenticationException(error);
+	}
+
+}

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

@@ -0,0 +1,170 @@
+/*
+ * Copyright 2020-2023 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.Collections;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.authentication.AbstractAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.session.SessionInformation;
+import org.springframework.security.oauth2.server.authorization.util.SpringAuthorizationServerVersion;
+import org.springframework.util.Assert;
+
+/**
+ * An {@link Authentication} implementation used for OpenID Connect 1.0 RP-Initiated Logout Endpoint.
+ *
+ * @author Joe Grandja
+ * @since 1.1.0
+ * @see AbstractAuthenticationToken
+ * @see OidcLogoutAuthenticationProvider
+ */
+public class OidcLogoutAuthenticationToken extends AbstractAuthenticationToken {
+	private static final long serialVersionUID = SpringAuthorizationServerVersion.SERIAL_VERSION_UID;
+	private final String idToken;
+	private final Authentication principal;
+	private final String sessionId;
+	private final SessionInformation sessionInformation;
+	private final String clientId;
+	private final String postLogoutRedirectUri;
+	private final String state;
+
+	/**
+	 * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters.
+	 *
+	 * @param idToken the ID Token previously issued by the Provider to the Client and used as a hint about the End-User's current authenticated session with the Client
+	 * @param principal the authenticated principal representing the End-User
+	 * @param sessionId the End-User's current authenticated session identifier with the Client
+	 * @param clientId the client identifier the ID Token was issued to
+	 * @param postLogoutRedirectUri the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed
+	 * @param state the opaque value used by the Client to maintain state between the logout request and the callback to the {@code postLogoutRedirectUri}
+	 */
+	public OidcLogoutAuthenticationToken(String idToken, Authentication principal, @Nullable String sessionId,
+			@Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) {
+		super(Collections.emptyList());
+		Assert.hasText(idToken, "idToken cannot be empty");
+		Assert.notNull(principal, "principal cannot be null");
+		this.idToken = idToken;
+		this.principal = principal;
+		this.sessionId = sessionId;
+		this.sessionInformation = null;
+		this.clientId = clientId;
+		this.postLogoutRedirectUri = postLogoutRedirectUri;
+		this.state = state;
+		setAuthenticated(false);
+	}
+
+	/**
+	 * Constructs an {@code OidcLogoutAuthenticationToken} using the provided parameters.
+	 *
+	 * @param idToken the ID Token previously issued by the Provider to the Client and used as a hint about the End-User's current authenticated session with the Client
+	 * @param principal the authenticated principal representing the End-User
+	 * @param sessionInformation  the End-User's current authenticated session information with the Client
+	 * @param clientId the client identifier the ID Token was issued to
+	 * @param postLogoutRedirectUri the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed
+	 * @param state the opaque value used by the Client to maintain state between the logout request and the callback to the {@code postLogoutRedirectUri}
+	 */
+	public OidcLogoutAuthenticationToken(String idToken, Authentication principal, @Nullable SessionInformation sessionInformation,
+			@Nullable String clientId, @Nullable String postLogoutRedirectUri, @Nullable String state) {
+		super(Collections.emptyList());
+		Assert.hasText(idToken, "idToken cannot be empty");
+		Assert.notNull(principal, "principal cannot be null");
+		this.idToken = idToken;
+		this.principal = principal;
+		this.sessionId = sessionInformation != null ? sessionInformation.getSessionId() : null;
+		this.sessionInformation = sessionInformation;
+		this.clientId = clientId;
+		this.postLogoutRedirectUri = postLogoutRedirectUri;
+		this.state = state;
+		setAuthenticated(true);
+	}
+
+	/**
+	 * Returns the authenticated principal representing the End-User.
+	 *
+	 * @return the authenticated principal
+	 */
+	@Override
+	public Object getPrincipal() {
+		return this.principal;
+	}
+
+	@Override
+	public Object getCredentials() {
+		return "";
+	}
+
+	/**
+	 * Returns the ID Token previously issued by the Provider to the Client and used as a hint
+	 * about the End-User's current authenticated session with the Client.
+	 *
+	 * @return the ID Token previously issued by the Provider to the Client
+	 */
+	public String getIdToken() {
+		return this.idToken;
+	}
+
+	/**
+	 * Returns the End-User's current authenticated session identifier with the Client.
+	 *
+	 * @return the End-User's current authenticated session identifier
+	 */
+	@Nullable
+	public String getSessionId() {
+		return this.sessionId;
+	}
+
+	/**
+	 * Returns the End-User's current authenticated session information with the Client.
+	 *
+	 * @return the End-User's current authenticated session information
+	 */
+	@Nullable
+	public SessionInformation getSessionInformation() {
+		return this.sessionInformation;
+	}
+
+	/**
+	 * Returns the client identifier the ID Token was issued to.
+	 *
+	 * @return the client identifier
+	 */
+	@Nullable
+	public String getClientId() {
+		return this.clientId;
+	}
+
+	/**
+	 * Returns the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed.
+	 *
+	 * @return the URI which the Client is requesting that the End-User's User Agent be redirected to after a logout has been performed
+	 */
+	@Nullable
+	public String getPostLogoutRedirectUri() {
+		return this.postLogoutRedirectUri;
+	}
+
+	/**
+	 * Returns the opaque value used by the Client to maintain state between the logout request and the callback to the {@link #getPostLogoutRedirectUri()}.
+	 *
+	 * @return the opaque value used by the Client to maintain state between the logout request and the callback to the {@link #getPostLogoutRedirectUri()}
+	 */
+	@Nullable
+	public String getState() {
+		return this.state;
+	}
+
+}

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

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -48,6 +48,11 @@ final class RegisteredClientOidcClientRegistrationConverter implements Converter
 		builder.redirectUris(redirectUris ->
 				redirectUris.addAll(registeredClient.getRedirectUris()));
 
+		if (!CollectionUtils.isEmpty(registeredClient.getPostLogoutRedirectUris())) {
+			builder.postLogoutRedirectUris(postLogoutRedirectUris ->
+					postLogoutRedirectUris.addAll(registeredClient.getPostLogoutRedirectUris()));
+		}
+
 		builder.grantTypes(grantTypes ->
 				registeredClient.getAuthorizationGrantTypes().forEach(authorizationGrantType ->
 						grantTypes.add(authorizationGrantType.getValue())));

+ 2 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -148,6 +148,7 @@ public class OidcClientRegistrationHttpMessageConverter extends AbstractHttpMess
 			claimConverters.put(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, MapOidcClientRegistrationConverter::convertClientSecretExpiresAt);
 			claimConverters.put(OidcClientMetadataClaimNames.CLIENT_NAME, stringConverter);
 			claimConverters.put(OidcClientMetadataClaimNames.REDIRECT_URIS, collectionStringConverter);
+			claimConverters.put(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, collectionStringConverter);
 			claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, stringConverter);
 			claimConverters.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, stringConverter);
 			claimConverters.put(OidcClientMetadataClaimNames.GRANT_TYPES, collectionStringConverter);

+ 218 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilter.java

@@ -0,0 +1,218 @@
+/*
+ * Copyright 2020-2023 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.web;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.core.log.LogMessage;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+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.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.oidc.web.authentication.OidcLogoutAuthenticationConverter;
+import org.springframework.security.web.DefaultRedirectStrategy;
+import org.springframework.security.web.RedirectStrategy;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
+import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
+import org.springframework.security.web.authentication.logout.SimpleUrlLogoutSuccessHandler;
+import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
+import org.springframework.security.web.util.matcher.OrRequestMatcher;
+import org.springframework.security.web.util.matcher.RequestMatcher;
+import org.springframework.util.Assert;
+import org.springframework.util.StringUtils;
+import org.springframework.web.filter.OncePerRequestFilter;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+/**
+ * A {@code Filter} that processes OpenID Connect 1.0 RP-Initiated Logout Requests.
+ *
+ * @author Joe Grandja
+ * @since 1.1.0
+ * @see OidcLogoutAuthenticationConverter
+ * @see OidcLogoutAuthenticationProvider
+ * @see <a href="https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout">2. RP-Initiated Logout</a>
+ */
+public final class OidcLogoutEndpointFilter extends OncePerRequestFilter {
+
+	/**
+	 * The default endpoint {@code URI} for OpenID Connect 1.0 RP-Initiated Logout Requests.
+	 */
+	private static final String DEFAULT_OIDC_LOGOUT_ENDPOINT_URI = "/connect/logout";
+
+	private final AuthenticationManager authenticationManager;
+	private final RequestMatcher logoutEndpointMatcher;
+	private final LogoutHandler logoutHandler;
+	private final LogoutSuccessHandler logoutSuccessHandler;
+	private final RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
+	private AuthenticationConverter authenticationConverter;
+	private AuthenticationSuccessHandler authenticationSuccessHandler = this::performLogout;
+	private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse;
+
+	/**
+	 * Constructs an {@code OidcLogoutEndpointFilter} using the provided parameters.
+	 *
+	 * @param authenticationManager the authentication manager
+	 */
+	public OidcLogoutEndpointFilter(AuthenticationManager authenticationManager) {
+		this(authenticationManager, DEFAULT_OIDC_LOGOUT_ENDPOINT_URI);
+	}
+
+	/**
+	 * Constructs an {@code OidcLogoutEndpointFilter} using the provided parameters.
+	 *
+	 * @param authenticationManager the authentication manager
+	 * @param logoutEndpointUri the endpoint {@code URI} for OpenID Connect 1.0 RP-Initiated Logout Requests
+	 */
+	public OidcLogoutEndpointFilter(AuthenticationManager authenticationManager,
+			String logoutEndpointUri) {
+		Assert.notNull(authenticationManager, "authenticationManager cannot be null");
+		Assert.hasText(logoutEndpointUri, "logoutEndpointUri cannot be empty");
+		this.authenticationManager = authenticationManager;
+		this.logoutEndpointMatcher = new OrRequestMatcher(
+				new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.GET.name()),
+				new AntPathRequestMatcher(logoutEndpointUri, HttpMethod.POST.name()));
+		this.logoutHandler = new SecurityContextLogoutHandler();
+		SimpleUrlLogoutSuccessHandler urlLogoutSuccessHandler = new SimpleUrlLogoutSuccessHandler();
+		urlLogoutSuccessHandler.setDefaultTargetUrl("/");
+		this.logoutSuccessHandler = urlLogoutSuccessHandler;
+		this.authenticationConverter = new OidcLogoutAuthenticationConverter();
+	}
+
+	@Override
+	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+			throws ServletException, IOException {
+
+		if (!this.logoutEndpointMatcher.matches(request)) {
+			filterChain.doFilter(request, response);
+			return;
+		}
+
+		try {
+			Authentication oidcLogoutAuthentication = this.authenticationConverter.convert(request);
+
+			Authentication oidcLogoutAuthenticationResult =
+					this.authenticationManager.authenticate(oidcLogoutAuthentication);
+
+			this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, oidcLogoutAuthenticationResult);
+		} catch (OAuth2AuthenticationException ex) {
+			if (this.logger.isTraceEnabled()) {
+				this.logger.trace(LogMessage.format("Logout request failed: %s", ex.getError()), ex);
+			}
+			this.authenticationFailureHandler.onAuthenticationFailure(request, response, ex);
+		} catch (Exception ex) {
+			OAuth2Error error = new OAuth2Error(
+					OAuth2ErrorCodes.INVALID_REQUEST,
+					"OpenID Connect 1.0 RP-Initiated Logout Error: " + ex.getMessage(),
+					"https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling");
+			if (this.logger.isTraceEnabled()) {
+				this.logger.trace(error, ex);
+			}
+			this.authenticationFailureHandler.onAuthenticationFailure(request, response,
+					new OAuth2AuthenticationException(error));
+		}
+	}
+
+	/**
+	 * Sets the {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest}
+	 * to an instance of {@link OidcLogoutAuthenticationToken} used for authenticating the request.
+	 *
+	 * @param authenticationConverter the {@link AuthenticationConverter} used when attempting to extract a Logout Request from {@link HttpServletRequest}
+	 */
+	public void setAuthenticationConverter(AuthenticationConverter authenticationConverter) {
+		Assert.notNull(authenticationConverter, "authenticationConverter cannot be null");
+		this.authenticationConverter = authenticationConverter;
+	}
+
+	/**
+	 * Sets the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken}
+	 * and performing the logout.
+	 *
+	 * @param authenticationSuccessHandler the {@link AuthenticationSuccessHandler} used for handling an {@link OidcLogoutAuthenticationToken}
+	 */
+	public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler authenticationSuccessHandler) {
+		Assert.notNull(authenticationSuccessHandler, "authenticationSuccessHandler cannot be null");
+		this.authenticationSuccessHandler = authenticationSuccessHandler;
+	}
+
+	/**
+	 * Sets the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException}
+	 * and returning the {@link OAuth2Error Error Response}.
+	 *
+	 * @param authenticationFailureHandler the {@link AuthenticationFailureHandler} used for handling an {@link OAuth2AuthenticationException}
+	 */
+	public void setAuthenticationFailureHandler(AuthenticationFailureHandler authenticationFailureHandler) {
+		Assert.notNull(authenticationFailureHandler, "authenticationFailureHandler cannot be null");
+		this.authenticationFailureHandler = authenticationFailureHandler;
+	}
+
+	private void performLogout(HttpServletRequest request, HttpServletResponse response,
+			Authentication authentication) throws IOException, ServletException {
+
+		OidcLogoutAuthenticationToken oidcLogoutAuthentication = (OidcLogoutAuthenticationToken) authentication;
+
+		// Check for active user session
+		if (oidcLogoutAuthentication.getSessionInformation() != null) {
+			// Perform logout
+			this.logoutHandler.logout(request, response,
+					(Authentication) oidcLogoutAuthentication.getPrincipal());
+		}
+
+		if (oidcLogoutAuthentication.isAuthenticated() &&
+				StringUtils.hasText(oidcLogoutAuthentication.getPostLogoutRedirectUri())) {
+			// Perform post-logout redirect
+			UriComponentsBuilder uriBuilder = UriComponentsBuilder
+					.fromUriString(oidcLogoutAuthentication.getPostLogoutRedirectUri());
+			String redirectUri;
+			if (StringUtils.hasText(oidcLogoutAuthentication.getState())) {
+				uriBuilder.queryParam(
+						OAuth2ParameterNames.STATE,
+						UriUtils.encode(oidcLogoutAuthentication.getState(), StandardCharsets.UTF_8));
+			}
+			redirectUri = uriBuilder.build(true).toUriString();		// build(true) -> Components are explicitly encoded
+			this.redirectStrategy.sendRedirect(request, response, redirectUri);
+		} else {
+			// Perform default redirect
+			this.logoutSuccessHandler.onLogoutSuccess(request, response,
+					(Authentication) oidcLogoutAuthentication.getPrincipal());
+		}
+	}
+
+	private void sendErrorResponse(HttpServletRequest request, HttpServletResponse response,
+			AuthenticationException exception) throws IOException {
+
+		OAuth2Error error = ((OAuth2AuthenticationException) exception).getError();
+		response.sendError(HttpStatus.BAD_REQUEST.value(), error.toString());
+	}
+
+}

+ 2 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilter.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -98,6 +98,7 @@ public final class OidcProviderConfigurationEndpointFilter extends OncePerReques
 				.tokenEndpointAuthenticationMethods(clientAuthenticationMethods())
 				.jwkSetUrl(asUrl(issuer, authorizationServerSettings.getJwkSetEndpoint()))
 				.userInfoEndpoint(asUrl(issuer, authorizationServerSettings.getOidcUserInfoEndpoint()))
+				.endSessionEndpoint(asUrl(issuer, authorizationServerSettings.getOidcLogoutEndpoint()))
 				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
 				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
 				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())

+ 120 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationConverter.java

@@ -0,0 +1,120 @@
+/*
+ * Copyright 2020-2023 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.web.authentication;
+
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpSession;
+
+import org.springframework.security.authentication.AnonymousAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContextHolder;
+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.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.oidc.web.OidcLogoutEndpointFilter;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+/**
+ * Attempts to extract an OpenID Connect 1.0 RP-Initiated Logout Request from {@link HttpServletRequest}
+ * and then converts to an {@link OidcLogoutAuthenticationToken} used for authenticating the request.
+ *
+ * @author Joe Grandja
+ * @since 1.1.0
+ * @see AuthenticationConverter
+ * @see OidcLogoutAuthenticationToken
+ * @see OidcLogoutEndpointFilter
+ */
+public final class OidcLogoutAuthenticationConverter implements AuthenticationConverter {
+	private static final Authentication ANONYMOUS_AUTHENTICATION = new AnonymousAuthenticationToken(
+			"anonymous", "anonymousUser", AuthorityUtils.createAuthorityList("ROLE_ANONYMOUS"));
+
+	@Override
+	public Authentication convert(HttpServletRequest request) {
+		MultiValueMap<String, String> parameters = getParameters(request);
+
+		// id_token_hint (REQUIRED)		// RECOMMENDED as per spec
+		String idTokenHint = request.getParameter("id_token_hint");
+		if (!StringUtils.hasText(idTokenHint) ||
+				request.getParameterValues("id_token_hint").length != 1) {
+			throwError(OAuth2ErrorCodes.INVALID_REQUEST, "id_token_hint");
+		}
+
+		Authentication principal = SecurityContextHolder.getContext().getAuthentication();
+		if (principal == null) {
+			principal = ANONYMOUS_AUTHENTICATION;
+		}
+
+		String sessionId = null;
+		HttpSession session = request.getSession(false);
+		if (session != null) {
+			sessionId = session.getId();
+		}
+
+		// client_id (OPTIONAL)
+		String clientId = parameters.getFirst(OAuth2ParameterNames.CLIENT_ID);
+		if (StringUtils.hasText(clientId) &&
+				parameters.get(OAuth2ParameterNames.CLIENT_ID).size() != 1) {
+			throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID);
+		}
+
+		// post_logout_redirect_uri (OPTIONAL)
+		String postLogoutRedirectUri = parameters.getFirst("post_logout_redirect_uri");
+		if (StringUtils.hasText(postLogoutRedirectUri) &&
+				parameters.get("post_logout_redirect_uri").size() != 1) {
+			throwError(OAuth2ErrorCodes.INVALID_REQUEST, "post_logout_redirect_uri");
+		}
+
+		// state (OPTIONAL)
+		String state = parameters.getFirst(OAuth2ParameterNames.STATE);
+		if (StringUtils.hasText(state) &&
+				parameters.get(OAuth2ParameterNames.STATE).size() != 1) {
+			throwError(OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE);
+		}
+
+		return new OidcLogoutAuthenticationToken(idTokenHint, principal,
+				sessionId, clientId, postLogoutRedirectUri, state);
+	}
+
+	private static MultiValueMap<String, String> getParameters(HttpServletRequest request) {
+		Map<String, String[]> parameterMap = request.getParameterMap();
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>(parameterMap.size());
+		parameterMap.forEach((key, values) -> {
+			if (values.length > 0) {
+				for (String value : values) {
+					parameters.add(key, value);
+				}
+			}
+		});
+		return parameters;
+	}
+
+	private static void throwError(String errorCode, String parameterName) {
+		OAuth2Error error = new OAuth2Error(
+				errorCode,
+				"OpenID Connect 1.0 Logout Request Parameter: " + parameterName,
+				"https://openid.net/specs/openid-connect-rpinitiated-1_0.html#ValidationAndErrorHandling");
+		throw new OAuth2AuthenticationException(error);
+	}
+
+}

+ 25 - 3
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -35,7 +35,7 @@ public final class AuthorizationServerSettings extends AbstractSettings {
 	}
 
 	/**
-	 * Returns the URL of the Authorization Server's Issuer Identifier
+	 * Returns the URL of the Authorization Server's Issuer Identifier.
 	 *
 	 * @return the URL of the Authorization Server's Issuer Identifier
 	 */
@@ -106,6 +106,16 @@ public final class AuthorizationServerSettings extends AbstractSettings {
 		return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_USER_INFO_ENDPOINT);
 	}
 
+	/**
+	 * Returns the OpenID Connect 1.0 Logout endpoint. The default is {@code /connect/logout}.
+	 *
+	 * @return the OpenID Connect 1.0 Logout endpoint
+	 * @since 1.1.0
+	 */
+	public String getOidcLogoutEndpoint() {
+		return getSetting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT);
+	}
+
 	/**
 	 * Constructs a new {@link Builder} with the default settings.
 	 *
@@ -119,7 +129,8 @@ public final class AuthorizationServerSettings extends AbstractSettings {
 				.tokenRevocationEndpoint("/oauth2/revoke")
 				.tokenIntrospectionEndpoint("/oauth2/introspect")
 				.oidcClientRegistrationEndpoint("/connect/register")
-				.oidcUserInfoEndpoint("/userinfo");
+				.oidcUserInfoEndpoint("/userinfo")
+				.oidcLogoutEndpoint("/connect/logout");
 	}
 
 	/**
@@ -222,6 +233,17 @@ public final class AuthorizationServerSettings extends AbstractSettings {
 			return setting(ConfigurationSettingNames.AuthorizationServer.OIDC_USER_INFO_ENDPOINT, oidcUserInfoEndpoint);
 		}
 
+		/**
+		 * Sets the OpenID Connect 1.0 Logout endpoint.
+		 *
+		 * @param oidcLogoutEndpoint the OpenID Connect 1.0 Logout endpoint
+		 * @return the {@link Builder} for further configuration
+		 * @since 1.1.0
+		 */
+		public Builder oidcLogoutEndpoint(String oidcLogoutEndpoint) {
+			return setting(ConfigurationSettingNames.AuthorizationServer.OIDC_LOGOUT_ENDPOINT, oidcLogoutEndpoint);
+		}
+
 		/**
 		 * Builds the {@link AuthorizationServerSettings}.
 		 *

+ 7 - 1
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/settings/ConfigurationSettingNames.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -116,6 +116,12 @@ public final class ConfigurationSettingNames {
 		 */
 		public static final String OIDC_USER_INFO_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("oidc-user-info-endpoint");
 
+		/**
+		 * Set the OpenID Connect 1.0 Logout endpoint.
+		 * @since 1.1.0
+		 */
+		public static final String OIDC_LOGOUT_ENDPOINT = AUTHORIZATION_SERVER_SETTINGS_NAMESPACE.concat("oidc-logout-endpoint");
+
 		private AuthorizationServer() {
 		}
 

+ 13 - 2
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/token/JwtGenerator.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -20,6 +20,7 @@ import java.time.temporal.ChronoUnit;
 import java.util.Collections;
 
 import org.springframework.lang.Nullable;
+import org.springframework.security.core.session.SessionInformation;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
@@ -126,7 +127,11 @@ public final class JwtGenerator implements OAuth2TokenGenerator<Jwt> {
 					claimsBuilder.claim(IdTokenClaimNames.NONCE, nonce);
 				}
 			}
-			// TODO Add 'auth_time' claim
+			SessionInformation sessionInformation = context.get(SessionInformation.class);
+			if (sessionInformation != null) {
+				claimsBuilder.claim("sid", sessionInformation.getSessionId());
+				claimsBuilder.claim(IdTokenClaimNames.AUTH_TIME, sessionInformation.getLastRequest());
+			}
 		}
 		// @formatter:on
 
@@ -147,6 +152,12 @@ public final class JwtGenerator implements OAuth2TokenGenerator<Jwt> {
 			if (context.getAuthorizationGrant() != null) {
 				jwtContextBuilder.authorizationGrant(context.getAuthorizationGrant());
 			}
+			if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
+				SessionInformation sessionInformation = context.get(SessionInformation.class);
+				if (sessionInformation != null) {
+					jwtContextBuilder.put(SessionInformation.class, sessionInformation);
+				}
+			}
 			// @formatter:on
 
 			JwtEncodingContext jwtContext = jwtContextBuilder.build();

+ 19 - 0
oauth2-authorization-server/src/main/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilter.java

@@ -35,6 +35,7 @@ import org.springframework.security.authentication.AuthenticationDetailsSource;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.session.SessionRegistry;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
 import org.springframework.security.oauth2.core.OAuth2Error;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponse;
@@ -54,6 +55,7 @@ import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
 import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
 import org.springframework.security.web.util.RedirectUrlBuilder;
 import org.springframework.security.web.util.UrlUtils;
 import org.springframework.security.web.util.matcher.AndRequestMatcher;
@@ -97,6 +99,7 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
 	private AuthenticationConverter authenticationConverter;
 	private AuthenticationSuccessHandler authenticationSuccessHandler = this::sendAuthorizationResponse;
 	private AuthenticationFailureHandler authenticationFailureHandler = this::sendErrorResponse;
+	private SessionAuthenticationStrategy sessionAuthenticationStrategy = (authentication, request, response) -> {};
 	private String consentPage;
 
 	/**
@@ -182,6 +185,9 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
 				return;
 			}
 
+			this.sessionAuthenticationStrategy.onAuthentication(
+					authenticationResult, request, response);
+
 			this.authenticationSuccessHandler.onAuthenticationSuccess(
 					request, response, authenticationResult);
 
@@ -238,6 +244,19 @@ public final class OAuth2AuthorizationEndpointFilter extends OncePerRequestFilte
 		this.authenticationFailureHandler = authenticationFailureHandler;
 	}
 
+	/**
+	 * Sets the {@link SessionAuthenticationStrategy} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken}
+	 * before calling the {@link AuthenticationSuccessHandler}.
+	 * If OpenID Connect is enabled, the default implementation tracks OpenID Connect sessions using a {@link SessionRegistry}.
+	 *
+	 * @param sessionAuthenticationStrategy the {@link SessionAuthenticationStrategy} used for handling an {@link OAuth2AuthorizationCodeRequestAuthenticationToken}
+	 * @since 1.1.0
+	 */
+	public void setSessionAuthenticationStrategy(SessionAuthenticationStrategy sessionAuthenticationStrategy) {
+		Assert.notNull(sessionAuthenticationStrategy, "sessionAuthenticationStrategy cannot be null");
+		this.sessionAuthenticationStrategy = sessionAuthenticationStrategy;
+	}
+
 	/**
 	 * Specify the URI to redirect Resource Owners to if consent is required. A default consent
 	 * page will be generated when this attribute is not specified.

+ 1 - 0
oauth2-authorization-server/src/main/resources/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql

@@ -8,6 +8,7 @@ CREATE TABLE oauth2_registered_client (
     client_authentication_methods varchar(1000) NOT NULL,
     authorization_grant_types varchar(1000) NOT NULL,
     redirect_uris varchar(1000) DEFAULT NULL,
+    post_logout_redirect_uris varchar(1000) DEFAULT NULL,
     scopes varchar(1000) NOT NULL,
     client_settings varchar(2000) NOT NULL,
     token_settings varchar(2000) NOT NULL,

+ 27 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -26,6 +26,8 @@ import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.OAuth2AccessToken;
 import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
 
@@ -47,6 +49,7 @@ public class InMemoryOAuth2AuthorizationServiceTests {
 			"code", Instant.now(), Instant.now().plus(5, ChronoUnit.MINUTES));
 	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
 	private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
+	private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
 	private InMemoryOAuth2AuthorizationService authorizationService;
 
 	@BeforeEach
@@ -263,6 +266,29 @@ public class InMemoryOAuth2AuthorizationServiceTests {
 		assertThat(authorization).isEqualTo(result);
 	}
 
+	@Test
+	public void findByTokenWhenIdTokenExistsThenFound() {
+		OidcIdToken idToken =  OidcIdToken.withTokenValue("id-token")
+				.issuer("https://provider.com")
+				.subject("subject")
+				.issuedAt(Instant.now().minusSeconds(60))
+				.expiresAt(Instant.now())
+				.build();
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+				.id(ID)
+				.principalName(PRINCIPAL_NAME)
+				.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+				.token(idToken)
+				.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(
+				idToken.getTokenValue(), ID_TOKEN_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(idToken.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
 	@Test
 	public void findByTokenWhenRefreshTokenExistsThenFound() {
 		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());

+ 31 - 3
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -49,6 +49,7 @@ import org.springframework.security.oauth2.core.OAuth2RefreshToken;
 import org.springframework.security.oauth2.core.OAuth2Token;
 import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
 import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
@@ -76,6 +77,7 @@ public class JdbcOAuth2AuthorizationServiceTests {
 	private static final String OAUTH2_AUTHORIZATION_SCHEMA_CLOB_DATA_TYPE_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema-clob-data-type.sql";
 	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
 	private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
+	private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
 	private static final String ID = "id";
 	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
 	private static final String PRINCIPAL_NAME = "principal";
@@ -344,6 +346,32 @@ public class JdbcOAuth2AuthorizationServiceTests {
 		assertThat(authorization).isEqualTo(result);
 	}
 
+	@Test
+	public void findByTokenWhenIdTokenExistsThenFound() {
+		when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId())))
+				.thenReturn(REGISTERED_CLIENT);
+		OidcIdToken idToken =  OidcIdToken.withTokenValue("id-token")
+				.issuer("https://provider.com")
+				.subject("subject")
+				.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+				.expiresAt(Instant.now().truncatedTo(ChronoUnit.MILLIS))
+				.build();
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+				.id(ID)
+				.principalName(PRINCIPAL_NAME)
+				.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+				.token(idToken, (metadata) ->
+						metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(
+				idToken.getTokenValue(), ID_TOKEN_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(idToken.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
 	@Test
 	public void findByTokenWhenRefreshTokenExistsThenFound() {
 		when(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId())))
@@ -494,7 +522,7 @@ public class JdbcOAuth2AuthorizationServiceTests {
 
 		private static final String PK_FILTER = "id = ?";
 		private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorizationCodeValue = ? OR " +
-				"accessTokenValue = ? OR refreshTokenValue = ?";
+				"accessTokenValue = ? OR oidcIdTokenValue = ? OR refreshTokenValue = ?";
 
 		// @formatter:off
 		private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES
@@ -539,7 +567,7 @@ public class JdbcOAuth2AuthorizationServiceTests {
 
 		@Override
 		public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
-			return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token);
+			return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token, token);
 		}
 
 		private OAuth2Authorization findBy(String filter, Object... args) {

+ 33 - 3
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -19,8 +19,11 @@ import java.security.Principal;
 import java.time.Duration;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.HashSet;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 
@@ -31,6 +34,8 @@ import org.mockito.ArgumentCaptor;
 
 import org.springframework.security.authentication.TestingAuthenticationToken;
 import org.springframework.security.core.Authentication;
+import org.springframework.security.core.session.SessionInformation;
+import org.springframework.security.core.session.SessionRegistry;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
@@ -95,6 +100,7 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 	private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
 	private OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer;
 	private OAuth2TokenGenerator<?> tokenGenerator;
+	private SessionRegistry sessionRegistry;
 	private OAuth2AuthorizationCodeAuthenticationProvider authenticationProvider;
 
 	@BeforeEach
@@ -116,8 +122,10 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 				return delegatingTokenGenerator.generate(context);
 			}
 		});
+		this.sessionRegistry = mock(SessionRegistry.class);
 		this.authenticationProvider = new OAuth2AuthorizationCodeAuthenticationProvider(
 				this.authorizationService, this.tokenGenerator);
+		this.authenticationProvider.setSessionRegistry(this.sessionRegistry);
 		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build();
 		AuthorizationServerContextHolder.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
 	}
@@ -146,6 +154,13 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 		assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeAuthenticationToken.class)).isTrue();
 	}
 
+	@Test
+	public void setSessionRegistryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setSessionRegistry(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("sessionRegistry cannot be null");
+	}
+
 	@Test
 	public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
@@ -456,6 +471,19 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 
 		when(this.jwtEncoder.encode(any())).thenReturn(createJwt());
 
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+
+		List<SessionInformation> sessions = new ArrayList<>();
+		sessions.add(new SessionInformation(principal.getPrincipal(),
+				"session3", Date.from(Instant.now())));
+		sessions.add(new SessionInformation(principal.getPrincipal(),
+				"session2", Date.from(Instant.now().minus(1, ChronoUnit.HOURS))));
+		sessions.add(new SessionInformation(principal.getPrincipal(),
+				"session1", Date.from(Instant.now().minus(2, ChronoUnit.HOURS))));
+		SessionInformation expectedSession = sessions.get(0);		// Most recent
+		when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(false)))
+				.thenReturn(sessions);
+
 		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication =
 				(OAuth2AccessTokenAuthenticationToken) this.authenticationProvider.authenticate(authentication);
 
@@ -464,7 +492,7 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 		// Access Token context
 		JwtEncodingContext accessTokenContext = jwtEncodingContextCaptor.getAllValues().get(0);
 		assertThat(accessTokenContext.getRegisteredClient()).isEqualTo(registeredClient);
-		assertThat(accessTokenContext.<Authentication>getPrincipal()).isEqualTo(authorization.getAttribute(Principal.class.getName()));
+		assertThat(accessTokenContext.<Authentication>getPrincipal()).isEqualTo(principal);
 		assertThat(accessTokenContext.getAuthorization()).isEqualTo(authorization);
 		assertThat(accessTokenContext.getAuthorization().getAccessToken()).isNull();
 		assertThat(accessTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
@@ -480,13 +508,15 @@ public class OAuth2AuthorizationCodeAuthenticationProviderTests {
 		// ID Token context
 		JwtEncodingContext idTokenContext = jwtEncodingContextCaptor.getAllValues().get(1);
 		assertThat(idTokenContext.getRegisteredClient()).isEqualTo(registeredClient);
-		assertThat(idTokenContext.<Authentication>getPrincipal()).isEqualTo(authorization.getAttribute(Principal.class.getName()));
+		assertThat(idTokenContext.<Authentication>getPrincipal()).isEqualTo(principal);
 		assertThat(idTokenContext.getAuthorization()).isNotEqualTo(authorization);
 		assertThat(idTokenContext.getAuthorization().getAccessToken()).isNotNull();
 		assertThat(idTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
 		assertThat(idTokenContext.getTokenType().getValue()).isEqualTo(OidcParameterNames.ID_TOKEN);
 		assertThat(idTokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
 		assertThat(idTokenContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant()).isEqualTo(authentication);
+		SessionInformation sessionInformation = idTokenContext.get(SessionInformation.class);
+		assertThat(sessionInformation).isNotNull().isSameAs(expectedSession);
 		assertThat(idTokenContext.getJwsHeader()).isNotNull();
 		assertThat(idTokenContext.getClaims()).isNotNull();
 

+ 5 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -294,6 +294,7 @@ public class JdbcRegisteredClientRepositoryTests {
 				+ "clientAuthenticationMethods, "
 				+ "authorizationGrantTypes, "
 				+ "redirectUris, "
+				+ "postLogoutRedirectUris, "
 				+ "scopes, "
 				+ "clientSettings,"
 				+ "tokenSettings";
@@ -305,7 +306,7 @@ public class JdbcRegisteredClientRepositoryTests {
 
 		// @formatter:off
 		private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME
-				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
 		// @formatter:on
 
 		private CustomJdbcRegisteredClientRepository(JdbcOperations jdbcOperations) {
@@ -353,6 +354,7 @@ public class JdbcRegisteredClientRepositoryTests {
 				Set<String> clientAuthenticationMethods = StringUtils.commaDelimitedListToSet(rs.getString("clientAuthenticationMethods"));
 				Set<String> authorizationGrantTypes = StringUtils.commaDelimitedListToSet(rs.getString("authorizationGrantTypes"));
 				Set<String> redirectUris = StringUtils.commaDelimitedListToSet(rs.getString("redirectUris"));
+				Set<String> postLogoutRedirectUris = StringUtils.commaDelimitedListToSet(rs.getString("postLogoutRedirectUris"));
 				Set<String> clientScopes = StringUtils.commaDelimitedListToSet(rs.getString("scopes"));
 
 				// @formatter:off
@@ -369,6 +371,7 @@ public class JdbcRegisteredClientRepositoryTests {
 								authorizationGrantTypes.forEach(grantType ->
 										grantTypes.add(resolveAuthorizationGrantType(grantType))))
 						.redirectUris((uris) -> uris.addAll(redirectUris))
+						.postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
 						.scopes((scopes) -> scopes.addAll(clientScopes));
 				// @formatter:on
 

+ 42 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -40,6 +40,7 @@ public class RegisteredClientTests {
 	private static final String CLIENT_ID = "client-1";
 	private static final String CLIENT_SECRET = "secret";
 	private static final Set<String> REDIRECT_URIS = Collections.singleton("https://example.com");
+	private static final Set<String> POST_LOGOUT_REDIRECT_URIS = Collections.singleton("https://example.com/oidc-post-logout");
 	private static final Set<String> SCOPES = Collections.unmodifiableSet(
 			Stream.of("openid", "profile", "email").collect(Collectors.toSet()));
 	private static final Set<ClientAuthenticationMethod> CLIENT_AUTHENTICATION_METHODS =
@@ -71,6 +72,7 @@ public class RegisteredClientTests {
 				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
 				.redirectUris(redirectUris -> redirectUris.addAll(REDIRECT_URIS))
+				.postLogoutRedirectUris(postLogoutRedirectUris -> postLogoutRedirectUris.addAll(POST_LOGOUT_REDIRECT_URIS))
 				.scopes(scopes -> scopes.addAll(SCOPES))
 				.build();
 
@@ -84,6 +86,7 @@ public class RegisteredClientTests {
 				.isEqualTo(Collections.singleton(AuthorizationGrantType.AUTHORIZATION_CODE));
 		assertThat(registration.getClientAuthenticationMethods()).isEqualTo(CLIENT_AUTHENTICATION_METHODS);
 		assertThat(registration.getRedirectUris()).isEqualTo(REDIRECT_URIS);
+		assertThat(registration.getPostLogoutRedirectUris()).isEqualTo(POST_LOGOUT_REDIRECT_URIS);
 		assertThat(registration.getScopes()).isEqualTo(SCOPES);
 	}
 
@@ -229,6 +232,35 @@ public class RegisteredClientTests {
 		).isInstanceOf(IllegalArgumentException.class);
 	}
 
+	@Test
+	public void buildWhenPostLogoutRedirectUriInvalidThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() ->
+				RegisteredClient.withId(ID)
+						.clientId(CLIENT_ID)
+						.clientSecret(CLIENT_SECRET)
+						.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+						.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+						.redirectUris(redirectUris -> redirectUris.addAll(REDIRECT_URIS))
+						.postLogoutRedirectUri("invalid URI")
+						.build()
+		).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenPostLogoutRedirectUriContainsFragmentThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() ->
+				RegisteredClient.withId(ID)
+						.clientId(CLIENT_ID)
+						.clientSecret(CLIENT_SECRET)
+						.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+						.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+						.redirectUri("https://example.com")
+						.postLogoutRedirectUri("https://example.com/index#fragment")
+						.scopes(scopes -> scopes.addAll(SCOPES))
+						.build()
+		).isInstanceOf(IllegalArgumentException.class);
+	}
+
 	@Test
 	public void buildWhenTwoAuthorizationGrantTypesAreProvidedThenBothAreRegistered() {
 		RegisteredClient registration = RegisteredClient.withId(ID)
@@ -345,6 +377,8 @@ public class RegisteredClientTests {
 		assertThat(registration.getAuthorizationGrantTypes()).isNotSameAs(updated.getAuthorizationGrantTypes());
 		assertThat(registration.getRedirectUris()).isEqualTo(updated.getRedirectUris());
 		assertThat(registration.getRedirectUris()).isNotSameAs(updated.getRedirectUris());
+		assertThat(registration.getPostLogoutRedirectUris()).isEqualTo(updated.getPostLogoutRedirectUris());
+		assertThat(registration.getPostLogoutRedirectUris()).isNotSameAs(updated.getPostLogoutRedirectUris());
 		assertThat(registration.getScopes()).isEqualTo(updated.getScopes());
 		assertThat(registration.getScopes()).isNotSameAs(updated.getScopes());
 		assertThat(registration.getClientSettings()).isEqualTo(updated.getClientSettings());
@@ -360,6 +394,7 @@ public class RegisteredClientTests {
 		String newSecret = "new-secret";
 		String newScope = "new-scope";
 		String newRedirectUri = "https://another-redirect-uri.com";
+		String newPostLogoutRedirectUri = "https://another-post-logout-redirect-uri.com";
 		RegisteredClient updated = RegisteredClient.from(registration)
 				.clientName(newName)
 				.clientSecret(newSecret)
@@ -371,6 +406,10 @@ public class RegisteredClientTests {
 					redirectUris.clear();
 					redirectUris.add(newRedirectUri);
 				})
+				.postLogoutRedirectUris(postLogoutRedirectUris -> {
+					postLogoutRedirectUris.clear();
+					postLogoutRedirectUris.add(newPostLogoutRedirectUri);
+				})
 				.build();
 
 		assertThat(registration.getClientName()).isNotEqualTo(newName);
@@ -381,6 +420,8 @@ public class RegisteredClientTests {
 		assertThat(updated.getScopes()).containsExactly(newScope);
 		assertThat(registration.getRedirectUris()).doesNotContain(newRedirectUri);
 		assertThat(updated.getRedirectUris()).containsExactly(newRedirectUri);
+		assertThat(registration.getPostLogoutRedirectUris()).doesNotContain(newPostLogoutRedirectUri);
+		assertThat(updated.getPostLogoutRedirectUris()).containsExactly(newPostLogoutRedirectUri);
 	}
 
 	@Test

+ 2 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java

@@ -38,6 +38,7 @@ public class TestRegisteredClients {
 				.redirectUri("https://example.com/callback-1")
 				.redirectUri("https://example.com/callback-2")
 				.redirectUri("https://example.com/callback-3")
+				.postLogoutRedirectUri("https://example.com/oidc-post-logout")
 				.scope("scope1");
 	}
 
@@ -52,6 +53,7 @@ public class TestRegisteredClients {
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
 				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
 				.redirectUri("https://example.com")
+				.postLogoutRedirectUri("https://example.com/oidc-post-logout")
 				.scope("scope1")
 				.scope("scope2");
 	}

+ 2 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -126,6 +126,7 @@ public class OidcProviderConfigurationTests {
 				jsonPath("$.token_endpoint_auth_methods_supported[3]").value(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()),
 				jsonPath("jwks_uri").value(ISSUER_URL.concat(this.authorizationServerSettings.getJwkSetEndpoint())),
 				jsonPath("userinfo_endpoint").value(ISSUER_URL.concat(this.authorizationServerSettings.getOidcUserInfoEndpoint())),
+				jsonPath("end_session_endpoint").value(ISSUER_URL.concat(this.authorizationServerSettings.getOidcLogoutEndpoint())),
 				jsonPath("response_types_supported").value(OAuth2AuthorizationResponseType.CODE.getValue()),
 				jsonPath("$.grant_types_supported[0]").value(AuthorizationGrantType.AUTHORIZATION_CODE.getValue()),
 				jsonPath("$.grant_types_supported[1]").value(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()),

+ 57 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java

@@ -47,6 +47,7 @@ import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
 import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
 import org.springframework.mock.http.client.MockClientHttpResponse;
 import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
 import org.springframework.security.authentication.TestingAuthenticationToken;
 import org.springframework.security.config.Customizer;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
@@ -123,6 +124,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
 public class OidcTests {
 	private static final String DEFAULT_AUTHORIZATION_ENDPOINT_URI = "/oauth2/authorize";
 	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+	private static final String DEFAULT_OIDC_LOGOUT_ENDPOINT_URI = "/connect/logout";
 	private static final String AUTHORITIES_CLAIM = "authorities";
 	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
 	private static EmbeddedDatabase db;
@@ -216,8 +218,9 @@ public class OidcTests {
 				servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus()));
 		OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
 
-		// Assert user authorities was propagated as claim in ID Token
 		Jwt idToken = this.jwtDecoder.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN));
+
+		// Assert user authorities was propagated as claim in ID Token
 		List<String> authoritiesClaim = idToken.getClaim(AUTHORITIES_CLAIM);
 		Authentication principal = authorization.getAttribute(Principal.class.getName());
 		Set<String> userAuthorities = new HashSet<>();
@@ -225,6 +228,59 @@ public class OidcTests {
 			userAuthorities.add(authority.getAuthority());
 		}
 		assertThat(authoritiesClaim).containsExactlyInAnyOrderElementsOf(userAuthorities);
+
+		// Assert sid claim was added in ID Token
+		assertThat(idToken.<String>getClaim("sid")).isNotNull();
+	}
+
+	@Test
+	public void requestWhenLogoutRequestThenLogout() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		// Login
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient);
+		MvcResult mvcResult = this.mvc.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+						.params(authorizationRequestParameters)
+						.with(user("user")))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+
+		MockHttpSession session = (MockHttpSession) mvcResult.getRequest().getSession();
+		assertThat(session.isNew()).isTrue();
+
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode, AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		// Get ID Token
+		mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+						.params(getTokenRequestParameters(registeredClient, authorization))
+						.header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth(
+								registeredClient.getClientId(), registeredClient.getClientSecret()))
+						.session(session))
+				.andExpect(status().isOk())
+				.andReturn();
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(
+				servletResponse.getContentAsByteArray(), HttpStatus.valueOf(servletResponse.getStatus()));
+		OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		String idToken = (String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN);
+
+		// Logout
+		mvcResult = this.mvc.perform(post(DEFAULT_OIDC_LOGOUT_ENDPOINT_URI)
+						.param("id_token_hint", idToken)
+						.session(session))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+		redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+
+		assertThat(redirectedUrl).matches("/");
+		assertThat(session.isInvalid()).isTrue();
 	}
 
 	@Test

+ 40 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -58,6 +58,7 @@ public class OidcClientRegistrationTests {
 				.clientSecretExpiresAt(clientSecretExpiresAt)
 				.clientName("client-name")
 				.redirectUri("https://client.example.com")
+				.postLogoutRedirectUri("https://client.example.com/oidc-post-logout")
 				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue())
 				.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256.getName())
 				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
@@ -79,6 +80,7 @@ public class OidcClientRegistrationTests {
 		assertThat(clientRegistration.getClientSecretExpiresAt()).isEqualTo(clientSecretExpiresAt);
 		assertThat(clientRegistration.getClientName()).isEqualTo("client-name");
 		assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com");
+		assertThat(clientRegistration.getPostLogoutRedirectUris()).containsOnly("https://client.example.com/oidc-post-logout");
 		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 		assertThat(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256.getName());
 		assertThat(clientRegistration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials");
@@ -108,6 +110,7 @@ public class OidcClientRegistrationTests {
 		claims.put(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, clientSecretExpiresAt);
 		claims.put(OidcClientMetadataClaimNames.CLIENT_NAME, "client-name");
 		claims.put(OidcClientMetadataClaimNames.REDIRECT_URIS, Collections.singletonList("https://client.example.com"));
+		claims.put(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, Collections.singletonList("https://client.example.com/oidc-post-logout"));
 		claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD, ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 		claims.put(OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_SIGNING_ALG, MacAlgorithm.HS256.getName());
 		claims.put(OidcClientMetadataClaimNames.GRANT_TYPES, Arrays.asList(
@@ -128,6 +131,7 @@ public class OidcClientRegistrationTests {
 		assertThat(clientRegistration.getClientSecretExpiresAt()).isEqualTo(clientSecretExpiresAt);
 		assertThat(clientRegistration.getClientName()).isEqualTo("client-name");
 		assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com");
+		assertThat(clientRegistration.getPostLogoutRedirectUris()).containsOnly("https://client.example.com/oidc-post-logout");
 		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 		assertThat(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256.getName());
 		assertThat(clientRegistration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials");
@@ -261,6 +265,41 @@ public class OidcClientRegistrationTests {
 		assertThat(clientRegistration.getRedirectUris()).containsExactly("https://client2.example.com");
 	}
 
+	@Test
+	public void buildWhenPostLogoutRedirectUrisNotListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder
+				.claim(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, "postLogoutRedirectUris");
+
+		assertThatIllegalArgumentException()
+				.isThrownBy(builder::build)
+				.withMessageStartingWith("post_logout_redirect_uris must be of type List");
+	}
+
+	@Test
+	public void buildWhenPostLogoutRedirectUrisEmptyListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder
+				.claim(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS, Collections.emptyList());
+
+		assertThatIllegalArgumentException()
+				.isThrownBy(builder::build)
+				.withMessage("post_logout_redirect_uris cannot be empty");
+	}
+
+	@Test
+	public void buildWhenPostLogoutRedirectUrisAddingOrRemovingThenCorrectValues() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.postLogoutRedirectUri("https://client1.example.com/oidc-post-logout")
+				.postLogoutRedirectUris(postLogoutRedirectUris -> {
+					postLogoutRedirectUris.clear();
+					postLogoutRedirectUris.add("https://client2.example.com/oidc-post-logout");
+				})
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.getPostLogoutRedirectUris()).containsExactly("https://client2.example.com/oidc-post-logout");
+	}
+
 	@Test
 	public void buildWhenGrantTypesNotListThenThrowIllegalArgumentException() {
 		OidcClientRegistration.Builder builder = this.minimalBuilder

+ 17 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -62,6 +62,7 @@ public class OidcProviderConfigurationTests {
 				.userInfoEndpoint("https://example.com/issuer1/userinfo")
 				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
 				.clientRegistrationEndpoint("https://example.com/issuer1/connect/register")
+				.endSessionEndpoint("https://example.com/issuer1/connect/logout")
 				.claim("a-claim", "a-value")
 				.build();
 
@@ -77,6 +78,7 @@ public class OidcProviderConfigurationTests {
 		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo"));
 		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
 		assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register"));
+		assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/logout"));
 		assertThat(providerConfiguration.<String>getClaim("a-claim")).isEqualTo("a-value");
 	}
 
@@ -118,6 +120,7 @@ public class OidcProviderConfigurationTests {
 		claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, Collections.singletonList("RS256"));
 		claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, "https://example.com/issuer1/userinfo");
 		claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, "https://example.com/issuer1/connect/register");
+		claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, "https://example.com/issuer1/connect/logout");
 		claims.put("some-claim", "some-value");
 
 		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build();
@@ -134,6 +137,7 @@ public class OidcProviderConfigurationTests {
 		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo"));
 		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
 		assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register"));
+		assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/logout"));
 		assertThat(providerConfiguration.<String>getClaim("some-claim")).isEqualTo("some-value");
 	}
 
@@ -150,6 +154,7 @@ public class OidcProviderConfigurationTests {
 		claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, Collections.singletonList("RS256"));
 		claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, url("https://example.com/issuer1/userinfo"));
 		claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, url("https://example.com/issuer1/connect/register"));
+		claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, url("https://example.com/issuer1/connect/logout"));
 		claims.put("some-claim", "some-value");
 
 		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build();
@@ -166,6 +171,7 @@ public class OidcProviderConfigurationTests {
 		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/issuer1/userinfo"));
 		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
 		assertThat(providerConfiguration.getClientRegistrationEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/register"));
+		assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/issuer1/connect/logout"));
 		assertThat(providerConfiguration.<String>getClaim("some-claim")).isEqualTo("some-value");
 	}
 
@@ -412,6 +418,16 @@ public class OidcProviderConfigurationTests {
 				.withMessage("clientRegistrationEndpoint must be a valid URL");
 	}
 
+	@Test
+	public void buildWhenEndSessionEndpointNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+				.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException()
+				.isThrownBy(builder::build)
+				.withMessage("endSessionEndpoint must be a valid URL");
+	}
+
 	@Test
 	public void responseTypesWhenAddingOrRemovingThenCorrectValues() {
 		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder

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

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -359,6 +359,78 @@ public class OidcClientRegistrationAuthenticationProviderTests {
 				eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN));
 	}
 
+	@Test
+	public void authenticateWhenInvalidPostLogoutRedirectUriThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientRegistration();
+		OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				jwt.getTokenValue(), jwt.getIssuedAt(),
+				jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE));
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(
+				registeredClient, jwtAccessToken, jwt.getClaims()).build();
+		when(this.authorizationService.findByToken(
+				eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN)))
+				.thenReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(
+				jwt, AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("https://client.example.com")
+				.postLogoutRedirectUri("invalid uri")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo("invalid_client_metadata");
+					assertThat(error.getDescription()).contains(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS);
+				});
+		verify(this.authorizationService).findByToken(
+				eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenPostLogoutRedirectUriContainsFragmentThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientRegistration();
+		OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				jwt.getTokenValue(), jwt.getIssuedAt(),
+				jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE));
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(
+				registeredClient, jwtAccessToken, jwt.getClaims()).build();
+		when(this.authorizationService.findByToken(
+				eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN)))
+				.thenReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(
+				jwt, AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("https://client.example.com")
+				.postLogoutRedirectUri("https://client.example.com/oidc-post-logout#fragment")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo("invalid_client_metadata");
+					assertThat(error.getDescription()).contains(OidcClientMetadataClaimNames.POST_LOGOUT_REDIRECT_URIS);
+				});
+		verify(this.authorizationService).findByToken(
+				eq(jwtAccessToken.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
 	@Test
 	public void authenticateWhenInvalidTokenEndpointAuthenticationMethodThenThrowOAuth2AuthenticationException() {
 		Jwt jwt = createJwtClientRegistration();
@@ -545,6 +617,7 @@ public class OidcClientRegistrationAuthenticationProviderTests {
 		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
 				.clientName("client-name")
 				.redirectUri("https://client.example.com")
+				.postLogoutRedirectUri("https://client.example.com/oidc-post-logout")
 				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
 				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
 				.scope("scope1")
@@ -588,6 +661,7 @@ public class OidcClientRegistrationAuthenticationProviderTests {
 		assertThat(registeredClientResult.getClientName()).isEqualTo(clientRegistration.getClientName());
 		assertThat(registeredClientResult.getClientAuthenticationMethods()).containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
 		assertThat(registeredClientResult.getRedirectUris()).containsExactly("https://client.example.com");
+		assertThat(registeredClientResult.getPostLogoutRedirectUris()).containsExactly("https://client.example.com/oidc-post-logout");
 		assertThat(registeredClientResult.getAuthorizationGrantTypes())
 				.containsExactlyInAnyOrder(AuthorizationGrantType.AUTHORIZATION_CODE, AuthorizationGrantType.CLIENT_CREDENTIALS);
 		assertThat(registeredClientResult.getScopes()).containsExactlyInAnyOrder("scope1", "scope2");
@@ -603,6 +677,8 @@ public class OidcClientRegistrationAuthenticationProviderTests {
 		assertThat(clientRegistrationResult.getClientName()).isEqualTo(registeredClientResult.getClientName());
 		assertThat(clientRegistrationResult.getRedirectUris())
 				.containsExactlyInAnyOrderElementsOf(registeredClientResult.getRedirectUris());
+		assertThat(clientRegistrationResult.getPostLogoutRedirectUris())
+				.containsExactlyInAnyOrderElementsOf(registeredClientResult.getPostLogoutRedirectUris());
 
 		List<String> grantTypes = new ArrayList<>();
 		registeredClientResult.getAuthorizationGrantTypes().forEach(authorizationGrantType ->

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

@@ -0,0 +1,426 @@
+/*
+ * Copyright 2020-2023 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.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.session.SessionInformation;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.IdTokenClaimNames;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+
+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.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link OidcLogoutAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ */
+public class OidcLogoutAuthenticationProviderTests {
+	private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
+	private RegisteredClientRepository registeredClientRepository;
+	private OAuth2AuthorizationService authorizationService;
+	private SessionRegistry sessionRegistry;
+	private AuthorizationServerSettings authorizationServerSettings;
+	private OidcLogoutAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.sessionRegistry = mock(SessionRegistry.class);
+		this.authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build();
+		TestAuthorizationServerContext authorizationServerContext =
+				new TestAuthorizationServerContext(this.authorizationServerSettings, null);
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+		this.authenticationProvider = new OidcLogoutAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.sessionRegistry);
+	}
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationProvider(null, this.authorizationService, this.sessionRegistry))
+				.withMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationProvider(this.registeredClientRepository, null, this.sessionRegistry))
+				.withMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenSessionRegistryNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationProvider(this.registeredClientRepository, this.authorizationService, null))
+				.withMessage("sessionRegistry cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOidcLogoutAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OidcLogoutAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenIdTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				"id-token", principal, "session-1", null, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+					assertThat(error.getDescription()).contains("id_token_hint");
+				});
+
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+	}
+
+	@Test
+	public void authenticateWhenMissingAudienceThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OidcIdToken idToken =  OidcIdToken.withTokenValue("id-token")
+				.issuer("https://provider.com")
+				.subject(principal.getName())
+				.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+				.expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, "session-1", null, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+					assertThat(error.getDescription()).contains(IdTokenClaimNames.AUD);
+				});
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenInvalidAudienceThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OidcIdToken idToken =  OidcIdToken.withTokenValue("id-token")
+				.issuer("https://provider.com")
+				.subject(principal.getName())
+				.audience(Collections.singleton(registeredClient.getClientId() + "-invalid"))
+				.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+				.expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, "session-1", null, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+					assertThat(error.getDescription()).contains(IdTokenClaimNames.AUD);
+				});
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		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))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, "session-1", registeredClient.getClientId() + "-invalid", null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+					assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
+				});
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenInvalidPostLogoutRedirectUriThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		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))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, "session-1", registeredClient.getClientId(),
+				"https://example.com/callback-1-invalid", null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+					assertThat(error.getDescription()).contains("post_logout_redirect_uri");
+				});
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenMissingSidThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		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))
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		String sessionId = "session-1";
+		List<SessionInformation> sessions = Collections.singletonList(
+				new SessionInformation(principal.getPrincipal(), sessionId, Date.from(Instant.now())));
+		when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true)))
+				.thenReturn(sessions);
+
+		principal.setAuthenticated(true);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, sessionId, null, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+					assertThat(error.getDescription()).contains("sid");
+				});
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenInvalidSidThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		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", "other-session")
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		String sessionId = "session-1";
+		List<SessionInformation> sessions = Collections.singletonList(
+				new SessionInformation(principal.getPrincipal(), sessionId, Date.from(Instant.now())));
+		when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true)))
+				.thenReturn(sessions);
+
+		principal.setAuthenticated(true);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, sessionId, null, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+					assertThat(error.getDescription()).contains("sid");
+				});
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenValidIdTokenThenAuthenticated() {
+		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", sessionId)
+				.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(principal.getName())
+				.token(idToken,
+						(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+				.build();
+		when(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+				.thenReturn(authorization);
+		when(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+				.thenReturn(registeredClient);
+
+		SessionInformation sessionInformation = new SessionInformation(
+				principal.getPrincipal(), sessionId, Date.from(Instant.now()));
+		List<SessionInformation> sessions = Collections.singletonList(sessionInformation);
+		when(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true)))
+				.thenReturn(sessions);
+
+		principal.setAuthenticated(true);
+		String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().toArray(new String[0])[0];
+		String state = "state";
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				idToken.getTokenValue(), principal, sessionId, registeredClient.getClientId(), postLogoutRedirectUri, state);
+
+		OidcLogoutAuthenticationToken authenticationResult =
+				(OidcLogoutAuthenticationToken) this.authenticationProvider.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(
+				eq(authentication.getIdToken()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(
+				eq(authorization.getRegisteredClientId()));
+
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(principal);
+		assertThat(authenticationResult.getCredentials().toString()).isEmpty();
+		assertThat(authenticationResult.getIdToken()).isEqualTo(idToken.getTokenValue());
+		assertThat(authenticationResult.getSessionId()).isEqualTo(sessionInformation.getSessionId());
+		assertThat(authenticationResult.getSessionInformation()).isEqualTo(sessionInformation);
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPostLogoutRedirectUri()).isEqualTo(postLogoutRedirectUri);
+		assertThat(authenticationResult.getState()).isEqualTo(state);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+	}
+
+}

+ 109 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java

@@ -0,0 +1,109 @@
+/*
+ * Copyright 2020-2023 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.sql.Date;
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.session.SessionInformation;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcLogoutAuthenticationToken}.
+ *
+ * @author Joe Grandja
+ */
+public class OidcLogoutAuthenticationTokenTests {
+	private final String idToken = "id-token";
+	private final TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+	private final String sessionId = "session-1";
+	private final SessionInformation sessionInformation = new SessionInformation(this.principal, "session-2", Date.from(Instant.now()));
+	private final String clientId = "client-1";
+	private final String postLogoutRedirectUri = "https://example.com/oidc-post-logout";
+	private final String state = "state-1";
+
+	@Test
+	public void constructorWhenIdTokenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationToken(
+						null, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state))
+				.withMessage("idToken cannot be empty");
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationToken(
+						null, this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state))
+				.withMessage("idToken cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenIdTokenEmptyThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationToken(
+						"", this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state))
+				.withMessage("idToken cannot be empty");
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationToken(
+						"", this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state))
+				.withMessage("idToken cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationToken(
+						this.idToken, null, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state))
+				.withMessage("principal cannot be null");
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OidcLogoutAuthenticationToken(
+						this.idToken, null, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state))
+				.withMessage("principal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenSessionIdProvidedThenCreated() {
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				this.idToken, this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getIdToken()).isEqualTo(this.idToken);
+		assertThat(authentication.getSessionId()).isEqualTo(this.sessionId);
+		assertThat(authentication.getSessionInformation()).isNull();
+		assertThat(authentication.getClientId()).isEqualTo(this.clientId);
+		assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri);
+		assertThat(authentication.getState()).isEqualTo(this.state);
+		assertThat(authentication.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void constructorWhenSessionInformationProvidedThenCreated() {
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				this.idToken, this.principal, this.sessionInformation, this.clientId, this.postLogoutRedirectUri, this.state);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getIdToken()).isEqualTo(this.idToken);
+		assertThat(authentication.getSessionId()).isEqualTo(this.sessionInformation.getSessionId());
+		assertThat(authentication.getSessionInformation()).isEqualTo(this.sessionInformation);
+		assertThat(authentication.getClientId()).isEqualTo(this.clientId);
+		assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri);
+		assertThat(authentication.getState()).isEqualTo(this.state);
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+}

+ 7 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -99,6 +99,9 @@ public class OidcClientRegistrationHttpMessageConverterTests {
 				+"		\"redirect_uris\": [\n"
 				+ "			\"https://client.example.com\"\n"
 				+ "		],\n"
+				+"		\"post_logout_redirect_uris\": [\n"
+				+ "			\"https://client.example.com/oidc-post-logout\"\n"
+				+ "		],\n"
 				+"		\"token_endpoint_auth_method\": \"client_secret_jwt\",\n"
 				+"		\"token_endpoint_auth_signing_alg\": \"HS256\",\n"
 				+"		\"grant_types\": [\n"
@@ -125,6 +128,7 @@ public class OidcClientRegistrationHttpMessageConverterTests {
 		assertThat(clientRegistration.getClientSecretExpiresAt()).isEqualTo(Instant.ofEpochSecond(1607637467L));
 		assertThat(clientRegistration.getClientName()).isEqualTo("client-name");
 		assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com");
+		assertThat(clientRegistration.getPostLogoutRedirectUris()).containsOnly("https://client.example.com/oidc-post-logout");
 		assertThat(clientRegistration.getTokenEndpointAuthenticationMethod()).isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
 		assertThat(clientRegistration.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256.getName());
 		assertThat(clientRegistration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "client_credentials");
@@ -183,6 +187,7 @@ public class OidcClientRegistrationHttpMessageConverterTests {
 				.clientSecretExpiresAt(Instant.ofEpochSecond(1607637467))
 				.clientName("client-name")
 				.redirectUri("https://client.example.com")
+				.postLogoutRedirectUri("https://client.example.com/oidc-post-logout")
 				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue())
 				.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256.getName())
 				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
@@ -208,6 +213,7 @@ public class OidcClientRegistrationHttpMessageConverterTests {
 		assertThat(clientRegistrationResponse).contains("\"client_secret_expires_at\":1607637467");
 		assertThat(clientRegistrationResponse).contains("\"client_name\":\"client-name\"");
 		assertThat(clientRegistrationResponse).contains("\"redirect_uris\":[\"https://client.example.com\"]");
+		assertThat(clientRegistrationResponse).contains("\"post_logout_redirect_uris\":[\"https://client.example.com/oidc-post-logout\"]");
 		assertThat(clientRegistrationResponse).contains("\"token_endpoint_auth_method\":\"client_secret_jwt\"");
 		assertThat(clientRegistrationResponse).contains("\"token_endpoint_auth_signing_alg\":\"HS256\"");
 		assertThat(clientRegistrationResponse).contains("\"grant_types\":[\"authorization_code\",\"client_credentials\"]");

+ 363 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java

@@ -0,0 +1,363 @@
+/*
+ * Copyright 2020-2023 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.web;
+
+import java.time.Instant;
+import java.util.Date;
+import java.util.function.Consumer;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.AuthenticationServiceException;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.core.session.SessionInformation;
+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.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.same;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link OidcLogoutEndpointFilter}.
+ *
+ * @author Joe Grandja
+ */
+public class OidcLogoutEndpointFilterTests {
+	private static final String DEFAULT_OIDC_LOGOUT_ENDPOINT_URI = "/connect/logout";
+	private AuthenticationManager authenticationManager;
+	private OidcLogoutEndpointFilter filter;
+	private TestingAuthenticationToken principal;
+
+	@BeforeEach
+	public void setUp() {
+		this.authenticationManager = mock(AuthenticationManager.class);
+		this.filter = new OidcLogoutEndpointFilter(this.authenticationManager);
+		this.principal = new TestingAuthenticationToken("principal", "credentials");
+		this.principal.setAuthenticated(true);
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(this.principal);
+		SecurityContextHolder.setContext(securityContext);
+	}
+
+	@AfterEach
+	public void cleanup() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcLogoutEndpointFilter(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authenticationManager cannot be null");
+	}
+
+	@Test
+	public void constructorWhenLogoutEndpointUriNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OidcLogoutEndpointFilter(this.authenticationManager, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("logoutEndpointUri cannot be empty");
+	}
+
+	@Test
+	public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.filter.setAuthenticationConverter(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authenticationConverter cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authenticationSuccessHandler cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.filter.setAuthenticationFailureHandler(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authenticationFailureHandler cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenNotLogoutRequestThenNotProcessed() throws Exception {
+		String requestUri = "/path";
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(filterChain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestMissingIdTokenHintThenInvalidRequestError() throws Exception {
+		doFilterWhenRequestInvalidParameterThenError(
+				createLogoutRequest(TestRegisteredClients.registeredClient().build()),
+				"id_token_hint",
+				OAuth2ErrorCodes.INVALID_REQUEST,
+				request -> request.removeParameter("id_token_hint"));
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestMultipleIdTokenHintThenInvalidRequestError() throws Exception {
+		doFilterWhenRequestInvalidParameterThenError(
+				createLogoutRequest(TestRegisteredClients.registeredClient().build()),
+				"id_token_hint",
+				OAuth2ErrorCodes.INVALID_REQUEST,
+				request -> request.addParameter("id_token_hint", "id-token-2"));
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestMultipleClientIdThenInvalidRequestError() throws Exception {
+		doFilterWhenRequestInvalidParameterThenError(
+				createLogoutRequest(TestRegisteredClients.registeredClient().build()),
+				OAuth2ParameterNames.CLIENT_ID,
+				OAuth2ErrorCodes.INVALID_REQUEST,
+				request -> request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-2"));
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestMultiplePostLogoutRedirectUriThenInvalidRequestError() throws Exception {
+		doFilterWhenRequestInvalidParameterThenError(
+				createLogoutRequest(TestRegisteredClients.registeredClient().build()),
+				"post_logout_redirect_uri",
+				OAuth2ErrorCodes.INVALID_REQUEST,
+				request -> request.addParameter("post_logout_redirect_uri", "https://example.com/callback-4"));
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestMultipleStateThenInvalidRequestError() throws Exception {
+		doFilterWhenRequestInvalidParameterThenError(
+				createLogoutRequest(TestRegisteredClients.registeredClient().build()),
+				OAuth2ParameterNames.STATE,
+				OAuth2ErrorCodes.INVALID_REQUEST,
+				request -> request.addParameter(OAuth2ParameterNames.STATE, "state-2"));
+	}
+
+	private void doFilterWhenRequestInvalidParameterThenError(MockHttpServletRequest request,
+			String parameterName, String errorCode, Consumer<MockHttpServletRequest> requestConsumer) throws Exception {
+
+		requestConsumer.accept(request);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
+		assertThat(response.getErrorMessage()).isEqualTo("[" + errorCode + "] OpenID Connect 1.0 Logout Request Parameter: " + parameterName);
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestAuthenticationExceptionThenErrorResponse() throws Exception {
+		OAuth2Error error = new OAuth2Error("errorCode", "errorDescription", "errorUri");
+		when(this.authenticationManager.authenticate(any()))
+				.thenThrow(new OAuth2AuthenticationException(error));
+
+		MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.authenticationManager).authenticate(any());
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
+		assertThat(response.getErrorMessage()).isEqualTo(error.toString());
+		assertThat(SecurityContextHolder.getContext().getAuthentication()).isSameAs(this.principal);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationConverterThenUsed() throws Exception {
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				"id-token", this.principal, (SessionInformation) null, null, null, null);
+
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		when(authenticationConverter.convert(any())).thenReturn(authentication);
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		when(this.authenticationManager.authenticate(any()))
+				.thenReturn(authentication);
+
+		MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(authenticationConverter).convert(any());
+		verify(this.authenticationManager).authenticate(any());
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception {
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				"id-token", this.principal, (SessionInformation) null, null, null, null);
+
+		AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
+
+		when(this.authenticationManager.authenticate(any()))
+				.thenReturn(authentication);
+
+		MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.authenticationManager).authenticate(any());
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), same(authentication));
+		verifyNoInteractions(filterChain);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exception {
+		AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		this.filter.setAuthenticationFailureHandler(authenticationFailureHandler);
+
+		when(this.authenticationManager.authenticate(any()))
+				.thenThrow(new AuthenticationServiceException("AuthenticationServiceException"));
+
+		MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build());
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		ArgumentCaptor<AuthenticationException> authenticationExceptionCaptor = ArgumentCaptor.forClass(AuthenticationException.class);
+		verify(this.authenticationManager).authenticate(any());
+		verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), authenticationExceptionCaptor.capture());
+		verifyNoInteractions(filterChain);
+
+		assertThat(authenticationExceptionCaptor.getValue())
+				.isInstanceOf(OAuth2AuthenticationException.class)
+				.extracting(ex -> ((OAuth2AuthenticationException) ex).getError())
+				.satisfies(error -> {
+					assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+					assertThat(error.getDescription()).contains("AuthenticationServiceException");
+				});
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestAuthenticatedThenLogout() throws Exception {
+		MockHttpServletRequest request = createLogoutRequest(TestRegisteredClients.registeredClient().build());
+		MockHttpSession session = (MockHttpSession) request.getSession(true);
+
+		SessionInformation sessionInformation = new SessionInformation(
+				this.principal, session.getId(), Date.from(Instant.now()));
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				"id-token", this.principal, sessionInformation, null, null, null);
+
+		when(this.authenticationManager.authenticate(any()))
+				.thenReturn(authentication);
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.authenticationManager).authenticate(any());
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getRedirectedUrl()).isEqualTo("/");
+		assertThat(session.isInvalid()).isTrue();
+		assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+	}
+
+	@Test
+	public void doFilterWhenLogoutRequestAuthenticatedWithPostLogoutRedirectUriThenPostLogoutRedirect() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		MockHttpServletRequest request = createLogoutRequest(registeredClient);
+		MockHttpSession session = (MockHttpSession) request.getSession(true);
+
+		SessionInformation sessionInformation = new SessionInformation(
+				this.principal, session.getId(), Date.from(Instant.now()));
+
+		String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().iterator().next();
+		String state = "state-1";
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(
+				"id-token", this.principal, sessionInformation,
+				registeredClient.getClientId(), postLogoutRedirectUri, state);
+
+		when(this.authenticationManager.authenticate(any()))
+				.thenReturn(authentication);
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.authenticationManager).authenticate(any());
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.FOUND.value());
+		assertThat(response.getRedirectedUrl()).isEqualTo(postLogoutRedirectUri + "?state=" + state);
+		assertThat(session.isInvalid()).isTrue();
+		assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull();
+	}
+
+	private static MockHttpServletRequest createLogoutRequest(RegisteredClient registeredClient) {
+		String requestUri = DEFAULT_OIDC_LOGOUT_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+
+		request.addParameter("id_token_hint", "id-token");
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		request.addParameter("post_logout_redirect_uri", registeredClient.getPostLogoutRedirectUris().iterator().next());
+		request.addParameter(OAuth2ParameterNames.STATE, "state");
+
+		return request;
+	}
+
+}

+ 4 - 1
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -92,6 +92,7 @@ public class OidcProviderConfigurationEndpointFilterTests {
 		String tokenEndpoint = "/oauth2/v1/token";
 		String jwkSetEndpoint = "/oauth2/v1/jwks";
 		String userInfoEndpoint = "/userinfo";
+		String logoutEndpoint = "/connect/logout";
 		String tokenRevocationEndpoint = "/oauth2/v1/revoke";
 		String tokenIntrospectionEndpoint = "/oauth2/v1/introspect";
 
@@ -101,6 +102,7 @@ public class OidcProviderConfigurationEndpointFilterTests {
 				.tokenEndpoint(tokenEndpoint)
 				.jwkSetEndpoint(jwkSetEndpoint)
 				.oidcUserInfoEndpoint(userInfoEndpoint)
+				.oidcLogoutEndpoint(logoutEndpoint)
 				.tokenRevocationEndpoint(tokenRevocationEndpoint)
 				.tokenIntrospectionEndpoint(tokenIntrospectionEndpoint)
 				.build();
@@ -132,6 +134,7 @@ public class OidcProviderConfigurationEndpointFilterTests {
 		assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]");
 		assertThat(providerConfigurationResponse).contains("\"id_token_signing_alg_values_supported\":[\"RS256\"]");
 		assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/issuer1/userinfo\"");
+		assertThat(providerConfigurationResponse).contains("\"end_session_endpoint\":\"https://example.com/issuer1/connect/logout\"");
 		assertThat(providerConfigurationResponse).contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\"]");
 	}
 

+ 14 - 2
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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 static org.assertj.core.api.Assertions.assertThatIllegalArgumentException
  * Tests for {@link AuthorizationServerSettings}.
  *
  * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
  */
 public class AuthorizationServerSettingsTests {
 
@@ -39,6 +40,7 @@ public class AuthorizationServerSettingsTests {
 		assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect");
 		assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint()).isEqualTo("/connect/register");
 		assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo("/userinfo");
+		assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo("/connect/logout");
 	}
 
 	@Test
@@ -50,6 +52,7 @@ public class AuthorizationServerSettingsTests {
 		String tokenIntrospectionEndpoint = "/oauth2/v1/introspect";
 		String oidcClientRegistrationEndpoint = "/connect/v1/register";
 		String oidcUserInfoEndpoint = "/connect/v1/userinfo";
+		String oidcLogoutEndpoint = "/connect/v1/logout";
 		String issuer = "https://example.com:9000";
 
 		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
@@ -62,6 +65,7 @@ public class AuthorizationServerSettingsTests {
 				.tokenRevocationEndpoint(tokenRevocationEndpoint)
 				.oidcClientRegistrationEndpoint(oidcClientRegistrationEndpoint)
 				.oidcUserInfoEndpoint(oidcUserInfoEndpoint)
+				.oidcLogoutEndpoint(oidcLogoutEndpoint)
 				.build();
 
 		assertThat(authorizationServerSettings.getIssuer()).isEqualTo(issuer);
@@ -72,6 +76,7 @@ public class AuthorizationServerSettingsTests {
 		assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo(tokenIntrospectionEndpoint);
 		assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint()).isEqualTo(oidcClientRegistrationEndpoint);
 		assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo(oidcUserInfoEndpoint);
+		assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo(oidcLogoutEndpoint);
 	}
 
 	@Test
@@ -81,7 +86,7 @@ public class AuthorizationServerSettingsTests {
 				.settings(settings -> settings.put("name2", "value2"))
 				.build();
 
-		assertThat(authorizationServerSettings.getSettings()).hasSize(9);
+		assertThat(authorizationServerSettings.getSettings()).hasSize(10);
 		assertThat(authorizationServerSettings.<String>getSetting("name1")).isEqualTo("value1");
 		assertThat(authorizationServerSettings.<String>getSetting("name2")).isEqualTo("value2");
 	}
@@ -142,4 +147,11 @@ public class AuthorizationServerSettingsTests {
 				.withMessage("value cannot be null");
 	}
 
+	@Test
+	public void oidcLogoutEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> AuthorizationServerSettings.builder().oidcLogoutEndpoint(null))
+				.withMessage("value cannot be null");
+	}
+
 }

+ 14 - 4
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -18,6 +18,7 @@ package org.springframework.security.oauth2.server.authorization.token;
 import java.security.Principal;
 import java.time.Instant;
 import java.time.temporal.ChronoUnit;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
@@ -27,6 +28,7 @@ import org.junit.jupiter.api.Test;
 import org.mockito.ArgumentCaptor;
 
 import org.springframework.security.core.Authentication;
+import org.springframework.security.core.session.SessionInformation;
 import org.springframework.security.oauth2.core.AuthorizationGrantType;
 import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
 import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
@@ -46,7 +48,6 @@ import org.springframework.security.oauth2.server.authorization.authentication.O
 import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
 import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
 import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
-import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContext;
 import org.springframework.security.oauth2.server.authorization.context.TestAuthorizationServerContext;
 import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
 import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
@@ -67,7 +68,7 @@ public class JwtGeneratorTests {
 	private JwtEncoder jwtEncoder;
 	private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
 	private JwtGenerator jwtGenerator;
-	private AuthorizationServerContext authorizationServerContext;
+	private TestAuthorizationServerContext authorizationServerContext;
 
 	@BeforeEach
 	public void setUp() {
@@ -168,16 +169,21 @@ public class JwtGeneratorTests {
 		OAuth2AuthorizationCodeAuthenticationToken authentication =
 				new OAuth2AuthorizationCodeAuthenticationToken("code", clientPrincipal, authorizationRequest.getRedirectUri(), null);
 
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+		SessionInformation sessionInformation = new SessionInformation(
+				principal.getPrincipal(), "session1", Date.from(Instant.now().minus(2, ChronoUnit.HOURS)));
+
 		// @formatter:off
 		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
 				.registeredClient(registeredClient)
-				.principal(authorization.getAttribute(Principal.class.getName()))
+				.principal(principal)
 				.authorizationServerContext(this.authorizationServerContext)
 				.authorization(authorization)
 				.authorizedScopes(authorization.getAuthorizedScopes())
 				.tokenType(ID_TOKEN_TOKEN_TYPE)
 				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
 				.authorizationGrant(authentication)
+				.put(SessionInformation.class, sessionInformation)
 				.build();
 		// @formatter:on
 
@@ -238,6 +244,10 @@ public class JwtGeneratorTests {
 					OAuth2AuthorizationRequest.class.getName());
 			String nonce = (String) authorizationRequest.getAdditionalParameters().get(OidcParameterNames.NONCE);
 			assertThat(jwtClaimsSet.<String>getClaim(IdTokenClaimNames.NONCE)).isEqualTo(nonce);
+
+			SessionInformation sessionInformation = tokenContext.get(SessionInformation.class);
+			assertThat(jwtClaimsSet.<String>getClaim("sid")).isEqualTo(sessionInformation.getSessionId());
+			assertThat(jwtClaimsSet.<Date>getClaim(IdTokenClaimNames.AUTH_TIME)).isEqualTo(sessionInformation.getLastRequest());
 		}
 	}
 

+ 33 - 0
oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/web/OAuth2AuthorizationEndpointFilterTests.java

@@ -58,6 +58,7 @@ import org.springframework.security.web.authentication.AuthenticationConverter;
 import org.springframework.security.web.authentication.AuthenticationFailureHandler;
 import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
 import org.springframework.security.web.authentication.WebAuthenticationDetails;
+import org.springframework.security.web.authentication.session.SessionAuthenticationStrategy;
 import org.springframework.util.StringUtils;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -151,6 +152,13 @@ public class OAuth2AuthorizationEndpointFilterTests {
 				.hasMessage("authenticationFailureHandler cannot be null");
 	}
 
+	@Test
+	public void setSessionAuthenticationStrategyWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.filter.setSessionAuthenticationStrategy(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("sessionAuthenticationStrategy cannot be null");
+	}
+
 	@Test
 	public void doFilterWhenNotAuthorizationRequestThenNotProcessed() throws Exception {
 		String requestUri = "/path";
@@ -383,6 +391,31 @@ public class OAuth2AuthorizationEndpointFilterTests {
 		verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), same(authenticationException));
 	}
 
+	@Test
+	public void doFilterWhenCustomSessionAuthenticationStrategyThenUsed() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult =
+				new OAuth2AuthorizationCodeRequestAuthenticationToken(
+						AUTHORIZATION_URI, registeredClient.getClientId(), principal, this.authorizationCode,
+						registeredClient.getRedirectUris().iterator().next(), STATE, registeredClient.getScopes());
+		authorizationCodeRequestAuthenticationResult.setAuthenticated(true);
+		when(this.authenticationManager.authenticate(any()))
+				.thenReturn(authorizationCodeRequestAuthenticationResult);
+
+		SessionAuthenticationStrategy sessionAuthenticationStrategy = mock(SessionAuthenticationStrategy.class);
+		this.filter.setSessionAuthenticationStrategy(sessionAuthenticationStrategy);
+
+		MockHttpServletRequest request = createAuthorizationRequest(registeredClient);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.authenticationManager).authenticate(any());
+		verifyNoInteractions(filterChain);
+		verify(sessionAuthenticationStrategy).onAuthentication(same(authorizationCodeRequestAuthenticationResult), any(), any());
+	}
+
 	@Test
 	public void doFilterWhenCustomAuthenticationDetailsSourceThenUsed() throws Exception {
 		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();

+ 1 - 0
oauth2-authorization-server/src/test/resources/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql

@@ -8,6 +8,7 @@ CREATE TABLE oauth2RegisteredClient (
     clientAuthenticationMethods varchar(1000) NOT NULL,
     authorizationGrantTypes varchar(1000) NOT NULL,
     redirectUris varchar(1000) DEFAULT NULL,
+    postLogoutRedirectUris varchar(1000) DEFAULT NULL,
     scopes varchar(1000) NOT NULL,
     clientSettings varchar(2000) NOT NULL,
     tokenSettings varchar(2000) NOT NULL,

+ 2 - 1
samples/custom-consent-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -94,6 +94,7 @@ public class AuthorizationServerConfig {
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
 				.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
 				.redirectUri("http://127.0.0.1:8080/authorized")
+				.postLogoutRedirectUri("http://127.0.0.1:8080/index")
 				.scope(OidcScopes.OPENID)
 				.scope(OidcScopes.PROFILE)
 				.scope("message.read")

+ 14 - 1
samples/custom-consent-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -19,11 +19,14 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.core.session.SessionRegistryImpl;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.session.HttpSessionEventPublisher;
 
 import static org.springframework.security.config.Customizer.withDefaults;
 
@@ -58,4 +61,14 @@ public class DefaultSecurityConfig {
 	}
 	// @formatter:on
 
+	@Bean
+	SessionRegistry sessionRegistry() {
+		return new SessionRegistryImpl();
+	}
+
+	@Bean
+	HttpSessionEventPublisher httpSessionEventPublisher() {
+		return new HttpSessionEventPublisher();
+	}
+
 }

+ 2 - 1
samples/default-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -88,6 +88,7 @@ public class AuthorizationServerConfig {
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
 				.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
 				.redirectUri("http://127.0.0.1:8080/authorized")
+				.postLogoutRedirectUri("http://127.0.0.1:8080/index")
 				.scope(OidcScopes.OPENID)
 				.scope(OidcScopes.PROFILE)
 				.scope("message.read")

+ 14 - 1
samples/default-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -19,11 +19,14 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.core.session.SessionRegistryImpl;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.session.HttpSessionEventPublisher;
 
 import static org.springframework.security.config.Customizer.withDefaults;
 
@@ -59,4 +62,14 @@ public class DefaultSecurityConfig {
 	}
 	// @formatter:on
 
+	@Bean
+	SessionRegistry sessionRegistry() {
+		return new SessionRegistryImpl();
+	}
+
+	@Bean
+	HttpSessionEventPublisher httpSessionEventPublisher() {
+		return new HttpSessionEventPublisher();
+	}
+
 }

+ 2 - 1
samples/federated-identity-authorizationserver/src/main/java/sample/config/AuthorizationServerConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -90,6 +90,7 @@ public class AuthorizationServerConfig {
 				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
 				.redirectUri("http://127.0.0.1:8080/login/oauth2/code/messaging-client-oidc")
 				.redirectUri("http://127.0.0.1:8080/authorized")
+				.postLogoutRedirectUri("http://127.0.0.1:8080/index")
 				.scope(OidcScopes.OPENID)
 				.scope(OidcScopes.PROFILE)
 				.scope("message.read")

+ 14 - 1
samples/federated-identity-authorizationserver/src/main/java/sample/config/DefaultSecurityConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -23,11 +23,14 @@ import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.Customizer;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.core.session.SessionRegistryImpl;
 import org.springframework.security.core.userdetails.User;
 import org.springframework.security.core.userdetails.UserDetails;
 import org.springframework.security.core.userdetails.UserDetailsService;
 import org.springframework.security.provisioning.InMemoryUserDetailsManager;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.session.HttpSessionEventPublisher;
 
 /**
  * @author Steve Riesenberg
@@ -66,4 +69,14 @@ public class DefaultSecurityConfig {
 	}
 	// @formatter:on
 
+	@Bean
+	public SessionRegistry sessionRegistry() {
+		return new SessionRegistryImpl();
+	}
+
+	@Bean
+	public HttpSessionEventPublisher httpSessionEventPublisher() {
+		return new HttpSessionEventPublisher();
+	}
+
 }

+ 22 - 2
samples/messages-client/src/main/java/sample/config/SecurityConfig.java

@@ -1,5 +1,5 @@
 /*
- * Copyright 2020-2022 the original author or authors.
+ * Copyright 2020-2023 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.
@@ -15,12 +15,16 @@
  */
 package sample.config;
 
+import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.security.config.annotation.web.builders.HttpSecurity;
 import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
 import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
+import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler;
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
 import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
 
 import static org.springframework.security.config.Customizer.withDefaults;
 
@@ -32,6 +36,9 @@ import static org.springframework.security.config.Customizer.withDefaults;
 @Configuration(proxyBeanMethods = false)
 public class SecurityConfig {
 
+	@Autowired
+	private ClientRegistrationRepository clientRegistrationRepository;
+
 	@Bean
 	WebSecurityCustomizer webSecurityCustomizer() {
 		return (web) -> web.ignoring().requestMatchers("/webjars/**");
@@ -46,9 +53,22 @@ public class SecurityConfig {
 			)
 			.oauth2Login(oauth2Login ->
 				oauth2Login.loginPage("/oauth2/authorization/messaging-client-oidc"))
-			.oauth2Client(withDefaults());
+			.oauth2Client(withDefaults())
+			.logout(logout ->
+				logout.logoutSuccessHandler(oidcLogoutSuccessHandler()));
 		return http.build();
 	}
 	// @formatter:on
 
+	private LogoutSuccessHandler oidcLogoutSuccessHandler() {
+		OidcClientInitiatedLogoutSuccessHandler oidcLogoutSuccessHandler =
+				new OidcClientInitiatedLogoutSuccessHandler(this.clientRegistrationRepository);
+
+		// Set the location that the End-User's User Agent will be redirected to
+		// after the logout has been performed at the Provider
+		oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/index");
+
+		return oidcLogoutSuccessHandler;
+	}
+
 }

+ 6 - 5
samples/messages-client/src/main/resources/templates/index.html

@@ -1,5 +1,5 @@
 <!DOCTYPE html>
-<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity5">
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org" xmlns:sec="https://www.thymeleaf.org/thymeleaf-extras-springsecurity6">
     <head>
         <title>Spring Security OAuth 2.0 Sample</title>
         <meta charset="utf-8" />
@@ -10,10 +10,11 @@
     <body>
         <div th:fragment="header">
             <nav class="navbar navbar-default">
-                <div class="container">
-                    <div class="container-fluid">
-                        <div class="navbar-collapse collapse" id="navbar">
-                        </div>
+                <div class="container-fluid">
+                    <div class="collapse navbar-collapse">
+                        <form class="navbar-form navbar-right" th:action="@{/logout}" method="post">
+                            <button type="submit" class="btn btn-default navbar-btn">Logout</button>
+                        </form>
                     </div>
                 </div>
             </nav>