Преглед на файлове

Manual move of spring-projects/spring-authorization-server src/test

Issue gh-17880
Joe Grandja преди 3 седмици
родител
ревизия
1ff1d88866
променени са 100 файла, в които са добавени 30746 реда и са изтрити 0 реда
  1. 113 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jose/TestJwks.java
  2. 148 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jose/TestKeys.java
  3. 60 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java
  4. 48 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java
  5. 150 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTests.java
  6. 336 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java
  7. 369 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationConsentServiceTests.java
  8. 875 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java
  9. 105 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTests.java
  10. 640 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataTests.java
  11. 141 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationTests.java
  12. 129 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java
  13. 113 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/aot/hint/OAuth2AuthorizationServerBeanRegistrationAotProcessorTests.java
  14. 388 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProviderTests.java
  15. 407 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionAuthenticationProviderTests.java
  16. 114 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactoryTests.java
  17. 79 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AccessTokenAuthenticationContextTests.java
  18. 98 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AccessTokenAuthenticationTokenTests.java
  19. 838 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java
  20. 90 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationTokenTests.java
  21. 817 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java
  22. 130 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationTokenTests.java
  23. 122 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContextTests.java
  24. 536 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationProviderTests.java
  25. 83 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationTokenTests.java
  26. 396 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java
  27. 83 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationTokenTests.java
  28. 468 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java
  29. 370 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java
  30. 499 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java
  31. 420 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java
  32. 422 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProviderTests.java
  33. 690 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java
  34. 81 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationTokenTests.java
  35. 48 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java
  36. 788 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java
  37. 144 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java
  38. 66 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java
  39. 304 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProviderTests.java
  40. 115 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationTokenTests.java
  41. 193 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProviderTests.java
  42. 103 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationTokenTests.java
  43. 305 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/PublicClientAuthenticationProviderTests.java
  44. 515 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java
  45. 204 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java
  46. 461 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java
  47. 440 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java
  48. 75 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/TestRegisteredClients.java
  49. 45 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configuration/OAuth2AuthorizationServerConfigurationTests.java
  50. 112 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configuration/RegisterMissingBeanPostProcessorTests.java
  51. 137 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/AuthorizationServerContextFilterTests.java
  52. 242 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizersTests.java
  53. 199 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/JwkSetTests.java
  54. 1509 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java
  55. 229 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerMetadataTests.java
  56. 688 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java
  57. 695 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java
  58. 645 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java
  59. 471 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java
  60. 609 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenIntrospectionTests.java
  61. 412 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenRevocationTests.java
  62. 918 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationTests.java
  63. 410 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcProviderConfigurationTests.java
  64. 787 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcTests.java
  65. 520 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcUserInfoTests.java
  66. 48 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/context/TestAuthorizationServerContext.java
  67. 246 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverterTests.java
  68. 174 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2TokenIntrospectionHttpMessageConverterTests.java
  69. 87 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2ModuleTests.java
  70. 48 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/jackson2/TestingAuthenticationTokenMixin.java
  71. 440 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java
  72. 525 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java
  73. 420 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientConfigurationAuthenticationProviderTests.java
  74. 791 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationProviderTests.java
  75. 92 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcClientRegistrationAuthenticationTokenTests.java
  76. 589 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationProviderTests.java
  77. 116 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcLogoutAuthenticationTokenTests.java
  78. 286 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcUserInfoAuthenticationProviderTests.java
  79. 61 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/authentication/OidcUserInfoAuthenticationTokenTests.java
  80. 278 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcClientRegistrationHttpMessageConverterTests.java
  81. 230 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java
  82. 230 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcUserInfoHttpMessageConverterTests.java
  83. 602 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcClientRegistrationEndpointFilterTests.java
  84. 348 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcLogoutEndpointFilterTests.java
  85. 184 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcProviderConfigurationEndpointFilterTests.java
  86. 344 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/OidcUserInfoEndpointFilterTests.java
  87. 94 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationSuccessHandlerTests.java
  88. 198 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java
  89. 85 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java
  90. 171 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java
  91. 146 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/test/SpringTestContext.java
  92. 54 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/test/SpringTestContextExtension.java
  93. 86 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DelegatingOAuth2TokenGeneratorTests.java
  94. 113 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtEncodingContextTests.java
  95. 360 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java
  96. 197 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGeneratorTests.java
  97. 70 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2RefreshTokenGeneratorTests.java
  98. 116 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenClaimsContextTests.java
  99. 96 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenClaimsSetTests.java
  100. 74 0
      oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java

+ 113 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jose/TestJwks.java

@@ -0,0 +1,113 @@
+/*
+ * Copyright 2020-2021 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.jose;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.interfaces.ECPrivateKey;
+import java.security.interfaces.ECPublicKey;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.util.UUID;
+
+import javax.crypto.SecretKey;
+
+import com.nimbusds.jose.jwk.Curve;
+import com.nimbusds.jose.jwk.ECKey;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.RSAKey;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestJwks {
+
+	private static final KeyPairGenerator rsaKeyPairGenerator;
+	static {
+		try {
+			rsaKeyPairGenerator = KeyPairGenerator.getInstance("RSA");
+			rsaKeyPairGenerator.initialize(2048);
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	// @formatter:off
+	public static final RSAKey DEFAULT_RSA_JWK =
+			jwk(
+					TestKeys.DEFAULT_PUBLIC_KEY,
+					TestKeys.DEFAULT_PRIVATE_KEY
+			).build();
+	// @formatter:on
+
+	// @formatter:off
+	public static final ECKey DEFAULT_EC_JWK =
+			jwk(
+					(ECPublicKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPublic(),
+					(ECPrivateKey) TestKeys.DEFAULT_EC_KEY_PAIR.getPrivate()
+			).build();
+	// @formatter:on
+
+	// @formatter:off
+	public static final OctetSequenceKey DEFAULT_SECRET_JWK =
+			jwk(
+					TestKeys.DEFAULT_SECRET_KEY
+			).build();
+	// @formatter:on
+
+	private TestJwks() {
+	}
+
+	public static RSAKey.Builder generateRsaJwk() {
+		KeyPair keyPair = rsaKeyPairGenerator.generateKeyPair();
+		RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
+		RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
+		// @formatter:off
+		return jwk(publicKey, privateKey)
+				.keyID(UUID.randomUUID().toString());
+		// @formatter:on
+	}
+
+	public static RSAKey.Builder jwk(RSAPublicKey publicKey, RSAPrivateKey privateKey) {
+		// @formatter:off
+		return new RSAKey.Builder(publicKey)
+				.privateKey(privateKey)
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID("rsa-jwk-kid");
+		// @formatter:on
+	}
+
+	public static ECKey.Builder jwk(ECPublicKey publicKey, ECPrivateKey privateKey) {
+		// @formatter:off
+		Curve curve = Curve.forECParameterSpec(publicKey.getParams());
+		return new ECKey.Builder(curve, publicKey)
+				.privateKey(privateKey)
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID("ec-jwk-kid");
+		// @formatter:on
+	}
+
+	public static OctetSequenceKey.Builder jwk(SecretKey secretKey) {
+		// @formatter:off
+		return new OctetSequenceKey.Builder(secretKey)
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID("secret-jwk-kid");
+		// @formatter:on
+	}
+
+}

+ 148 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jose/TestKeys.java

@@ -0,0 +1,148 @@
+/*
+ * Copyright 2020-2022 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.jose;
+
+import java.math.BigInteger;
+import java.security.KeyFactory;
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.interfaces.RSAPrivateKey;
+import java.security.interfaces.RSAPublicKey;
+import java.security.spec.ECFieldFp;
+import java.security.spec.ECParameterSpec;
+import java.security.spec.ECPoint;
+import java.security.spec.EllipticCurve;
+import java.security.spec.InvalidKeySpecException;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Base64;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestKeys {
+
+	public static final KeyFactory kf;
+	static {
+		try {
+			kf = KeyFactory.getInstance("RSA");
+		}
+		catch (NoSuchAlgorithmException ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+	public static final String DEFAULT_ENCODED_SECRET_KEY = "bCzY/M48bbkwBEWjmNSIEPfwApcvXOnkCxORBEbPr+4=";
+
+	public static final SecretKey DEFAULT_SECRET_KEY = new SecretKeySpec(
+			Base64.getDecoder().decode(DEFAULT_ENCODED_SECRET_KEY), "AES");
+
+	// @formatter:off
+	public static final String DEFAULT_RSA_PUBLIC_KEY = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA3FlqJr5TRskIQIgdE3Dd"
+			+ "7D9lboWdcTUT8a+fJR7MAvQm7XXNoYkm3v7MQL1NYtDvL2l8CAnc0WdSTINU6IRv"
+			+ "c5Kqo2Q4csNX9SHOmEfzoROjQqahEcve1jBXluoCXdYuYpx4/1tfRgG6ii4Uhxh6"
+			+ "iI8qNMJQX+fLfqhbfYfxBQVRPywBkAbIP4x1EAsbC6FSNmkhCxiMNqEgxaIpY8C2"
+			+ "kJdJ/ZIV+WW4noDdzpKqHcwmB8FsrumlVY/DNVvUSDIipiq9PbP4H99TXN1o746o"
+			+ "RaNa07rq1hoCgMSSy+85SagCoxlmyE+D+of9SsMY8Ol9t0rdzpobBuhyJ/o5dfvj"
+			+ "KwIDAQAB";
+	// @formatter:on
+
+	public static final RSAPublicKey DEFAULT_PUBLIC_KEY;
+	static {
+		X509EncodedKeySpec spec = new X509EncodedKeySpec(Base64.getDecoder().decode(DEFAULT_RSA_PUBLIC_KEY));
+		try {
+			DEFAULT_PUBLIC_KEY = (RSAPublicKey) kf.generatePublic(spec);
+		}
+		catch (InvalidKeySpecException ex) {
+			throw new IllegalArgumentException(ex);
+		}
+	}
+
+	// @formatter:off
+	public static final String DEFAULT_RSA_PRIVATE_KEY = "MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDcWWomvlNGyQhA"
+			+ "iB0TcN3sP2VuhZ1xNRPxr58lHswC9Cbtdc2hiSbe/sxAvU1i0O8vaXwICdzRZ1JM"
+			+ "g1TohG9zkqqjZDhyw1f1Ic6YR/OhE6NCpqERy97WMFeW6gJd1i5inHj/W19GAbqK"
+			+ "LhSHGHqIjyo0wlBf58t+qFt9h/EFBVE/LAGQBsg/jHUQCxsLoVI2aSELGIw2oSDF"
+			+ "oiljwLaQl0n9khX5ZbiegN3OkqodzCYHwWyu6aVVj8M1W9RIMiKmKr09s/gf31Nc"
+			+ "3WjvjqhFo1rTuurWGgKAxJLL7zlJqAKjGWbIT4P6h/1Kwxjw6X23St3OmhsG6HIn"
+			+ "+jl1++MrAgMBAAECggEBAMf820wop3pyUOwI3aLcaH7YFx5VZMzvqJdNlvpg1jbE"
+			+ "E2Sn66b1zPLNfOIxLcBG8x8r9Ody1Bi2Vsqc0/5o3KKfdgHvnxAB3Z3dPh2WCDek"
+			+ "lCOVClEVoLzziTuuTdGO5/CWJXdWHcVzIjPxmK34eJXioiLaTYqN3XKqKMdpD0ZG"
+			+ "mtNTGvGf+9fQ4i94t0WqIxpMpGt7NM4RHy3+Onggev0zLiDANC23mWrTsUgect/7"
+			+ "62TYg8g1bKwLAb9wCBT+BiOuCc2wrArRLOJgUkj/F4/gtrR9ima34SvWUyoUaKA0"
+			+ "bi4YBX9l8oJwFGHbU9uFGEMnH0T/V0KtIB7qetReywkCgYEA9cFyfBIQrYISV/OA"
+			+ "+Z0bo3vh2aL0QgKrSXZ924cLt7itQAHNZ2ya+e3JRlTczi5mnWfjPWZ6eJB/8MlH"
+			+ "Gpn12o/POEkU+XjZZSPe1RWGt5g0S3lWqyx9toCS9ACXcN9tGbaqcFSVI73zVTRA"
+			+ "8J9grR0fbGn7jaTlTX2tnlOTQ60CgYEA5YjYpEq4L8UUMFkuj+BsS3u0oEBnzuHd"
+			+ "I9LEHmN+CMPosvabQu5wkJXLuqo2TxRnAznsA8R3pCLkdPGoWMCiWRAsCn979TdY"
+			+ "QbqO2qvBAD2Q19GtY7lIu6C35/enQWzJUMQE3WW0OvjLzZ0l/9mA2FBRR+3F9A1d"
+			+ "rBdnmv0c3TcCgYEAi2i+ggVZcqPbtgrLOk5WVGo9F1GqUBvlgNn30WWNTx4zIaEk"
+			+ "HSxtyaOLTxtq2odV7Kr3LGiKxwPpn/T+Ief+oIp92YcTn+VfJVGw4Z3BezqbR8lA"
+			+ "Uf/+HF5ZfpMrVXtZD4Igs3I33Duv4sCuqhEvLWTc44pHifVloozNxYfRfU0CgYBN"
+			+ "HXa7a6cJ1Yp829l62QlJKtx6Ymj95oAnQu5Ez2ROiZMqXRO4nucOjGUP55Orac1a"
+			+ "FiGm+mC/skFS0MWgW8evaHGDbWU180wheQ35hW6oKAb7myRHtr4q20ouEtQMdQIF"
+			+ "snV39G1iyqeeAsf7dxWElydXpRi2b68i3BIgzhzebQKBgQCdUQuTsqV9y/JFpu6H"
+			+ "c5TVvhG/ubfBspI5DhQqIGijnVBzFT//UfIYMSKJo75qqBEyP2EJSmCsunWsAFsM"
+			+ "TszuiGTkrKcZy9G0wJqPztZZl2F2+bJgnA6nBEV7g5PA4Af+QSmaIhRwqGDAuROR"
+			+ "47jndeyIaMTNETEmOnms+as17g==";
+	// @formatter:on
+
+	public static final RSAPrivateKey DEFAULT_PRIVATE_KEY;
+	static {
+		PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(DEFAULT_RSA_PRIVATE_KEY));
+		try {
+			DEFAULT_PRIVATE_KEY = (RSAPrivateKey) kf.generatePrivate(spec);
+		}
+		catch (InvalidKeySpecException ex) {
+			throw new IllegalArgumentException(ex);
+		}
+	}
+
+	public static final KeyPair DEFAULT_RSA_KEY_PAIR = new KeyPair(DEFAULT_PUBLIC_KEY, DEFAULT_PRIVATE_KEY);
+
+	public static final KeyPair DEFAULT_EC_KEY_PAIR = generateEcKeyPair();
+
+	static KeyPair generateEcKeyPair() {
+		EllipticCurve ellipticCurve = new EllipticCurve(
+				new ECFieldFp(new BigInteger(
+						"115792089210356248762697446949407573530086143415290314195533631308867097853951")),
+				new BigInteger("115792089210356248762697446949407573530086143415290314195533631308867097853948"),
+				new BigInteger("41058363725152142129326129780047268409114441015993725554835256314039467401291"));
+		ECPoint ecPoint = new ECPoint(
+				new BigInteger("48439561293906451759052585252797914202762949526041747995844080717082404635286"),
+				new BigInteger("36134250956749795798585127919587881956611106672985015071877198253568414405109"));
+		ECParameterSpec ecParameterSpec = new ECParameterSpec(ellipticCurve, ecPoint,
+				new BigInteger("115792089210356248762697446949407573529996955224135760342422259061068512044369"), 1);
+
+		KeyPair keyPair;
+		try {
+			KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC");
+			keyPairGenerator.initialize(ecParameterSpec);
+			keyPair = keyPairGenerator.generateKeyPair();
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+		return keyPair;
+	}
+
+	private TestKeys() {
+	}
+
+}

+ 60 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jwt/TestJwsHeaders.java

@@ -0,0 +1,60 @@
+/*
+ * Copyright 2020-2022 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.jwt;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestJwsHeaders {
+
+	private TestJwsHeaders() {
+	}
+
+	public static JwsHeader.Builder jwsHeader() {
+		return jwsHeader(SignatureAlgorithm.RS256);
+	}
+
+	public static JwsHeader.Builder jwsHeader(SignatureAlgorithm signatureAlgorithm) {
+		// @formatter:off
+		return JwsHeader.with(signatureAlgorithm)
+				.jwkSetUrl("https://provider.com/oauth2/jwks")
+				.jwk(rsaJwk())
+				.keyId("keyId")
+				.x509Url("https://provider.com/oauth2/x509")
+				.x509CertificateChain(Arrays.asList("x509Cert1", "x509Cert2"))
+				.x509SHA1Thumbprint("x509SHA1Thumbprint")
+				.x509SHA256Thumbprint("x509SHA256Thumbprint")
+				.type("JWT")
+				.contentType("jwt-content-type")
+				.header("custom-header-name", "custom-header-value");
+		// @formatter:on
+	}
+
+	private static Map<String, Object> rsaJwk() {
+		Map<String, Object> rsaJwk = new HashMap<>();
+		rsaJwk.put("kty", "RSA");
+		rsaJwk.put("n", "modulus");
+		rsaJwk.put("e", "exponent");
+		return rsaJwk;
+	}
+
+}

+ 48 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/jwt/TestJwtClaimsSets.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020-2021 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.jwt;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestJwtClaimsSets {
+
+	private TestJwtClaimsSets() {
+	}
+
+	public static JwtClaimsSet.Builder jwtClaimsSet() {
+		String issuer = "https://provider.com";
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+
+		// @formatter:off
+		return JwtClaimsSet.builder()
+				.issuer(issuer)
+				.subject("subject")
+				.audience(Collections.singletonList("client-1"))
+				.issuedAt(issuedAt)
+				.notBefore(issuedAt)
+				.expiresAt(expiresAt)
+				.id("jti")
+				.claim("custom-claim-name", "custom-claim-value");
+		// @formatter:on
+	}
+
+}

+ 150 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationConsentServiceTests.java

@@ -0,0 +1,150 @@
+/*
+ * Copyright 2020-2022 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;
+
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link InMemoryOAuth2AuthorizationConsentService}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class InMemoryOAuth2AuthorizationConsentServiceTests {
+
+	private static final String REGISTERED_CLIENT_ID = "registered-client-id";
+
+	private static final String PRINCIPAL_NAME = "principal-name";
+
+	private static final OAuth2AuthorizationConsent AUTHORIZATION_CONSENT = OAuth2AuthorizationConsent
+		.withId(REGISTERED_CLIENT_ID, PRINCIPAL_NAME)
+		.authority(new SimpleGrantedAuthority("some.authority"))
+		.build();
+
+	private InMemoryOAuth2AuthorizationConsentService authorizationConsentService;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationConsentService = new InMemoryOAuth2AuthorizationConsentService();
+		this.authorizationConsentService.save(AUTHORIZATION_CONSENT);
+	}
+
+	@Test
+	public void constructorVarargsWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService((OAuth2AuthorizationConsent) null))
+			.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void constructorListWhenAuthorizationConsentsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new InMemoryOAuth2AuthorizationConsentService((List<OAuth2AuthorizationConsent>) null))
+			.withMessage("authorizationConsents cannot be null");
+	}
+
+	@Test
+	public void constructorWhenDuplicateAuthorizationConsentsThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(
+					() -> new InMemoryOAuth2AuthorizationConsentService(AUTHORIZATION_CONSENT, AUTHORIZATION_CONSENT))
+			.withMessage(
+					"The authorizationConsent must be unique. Found duplicate, with registered client id: [registered-client-id] and principal name: [principal-name]");
+	}
+
+	@Test
+	public void saveWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.authorizationConsentService.save(null))
+			.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void saveWhenAuthorizationConsentNewThenSaved() {
+		OAuth2AuthorizationConsent expectedAuthorizationConsent = OAuth2AuthorizationConsent
+			.withId("new-client", "new-principal")
+			.authority(new SimpleGrantedAuthority("new.authority"))
+			.build();
+
+		this.authorizationConsentService.save(expectedAuthorizationConsent);
+
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService.findById("new-client",
+				"new-principal");
+		assertThat(authorizationConsent).isEqualTo(expectedAuthorizationConsent);
+	}
+
+	@Test
+	public void saveWhenAuthorizationConsentExistsThenUpdated() {
+		OAuth2AuthorizationConsent expectedAuthorizationConsent = OAuth2AuthorizationConsent.from(AUTHORIZATION_CONSENT)
+			.authority(new SimpleGrantedAuthority("new.authority"))
+			.build();
+
+		this.authorizationConsentService.save(expectedAuthorizationConsent);
+
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService
+			.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(), AUTHORIZATION_CONSENT.getPrincipalName());
+		assertThat(authorizationConsent).isEqualTo(expectedAuthorizationConsent);
+		assertThat(authorizationConsent).isNotEqualTo(AUTHORIZATION_CONSENT);
+	}
+
+	@Test
+	public void removeWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.authorizationConsentService.remove(null))
+			.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void removeWhenAuthorizationConsentProvidedThenRemoved() {
+		this.authorizationConsentService.remove(AUTHORIZATION_CONSENT);
+		assertThat(this.authorizationConsentService.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(),
+				AUTHORIZATION_CONSENT.getPrincipalName()))
+			.isNull();
+	}
+
+	@Test
+	public void findByIdWhenRegisteredClientIdNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authorizationConsentService.findById(null, "some-user"))
+			.withMessage("registeredClientId cannot be empty");
+	}
+
+	@Test
+	public void findByIdWhenPrincipalNameNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authorizationConsentService.findById("some-client", null))
+			.withMessage("principalName cannot be empty");
+	}
+
+	@Test
+	public void findByIdWhenAuthorizationConsentExistsThenFound() {
+		assertThat(this.authorizationConsentService.findById(REGISTERED_CLIENT_ID, PRINCIPAL_NAME))
+			.isEqualTo(AUTHORIZATION_CONSENT);
+	}
+
+	@Test
+	public void findByIdWhenAuthorizationConsentDoesNotExistThenNull() {
+		this.authorizationConsentService.save(AUTHORIZATION_CONSENT);
+		assertThat(this.authorizationConsentService.findById("unknown-client", PRINCIPAL_NAME)).isNull();
+		assertThat(this.authorizationConsentService.findById(REGISTERED_CLIENT_ID, "unknown-user")).isNull();
+	}
+
+}

+ 336 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/InMemoryOAuth2AuthorizationServiceTests.java

@@ -0,0 +1,336 @@
+/*
+ * 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;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link InMemoryOAuth2AuthorizationService}.
+ *
+ * @author Krisztian Toth
+ * @author Joe Grandja
+ */
+public class InMemoryOAuth2AuthorizationServiceTests {
+
+	private static final String ID = "id";
+
+	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
+
+	private static final String PRINCIPAL_NAME = "principal";
+
+	private static final AuthorizationGrantType AUTHORIZATION_GRANT_TYPE = AuthorizationGrantType.AUTHORIZATION_CODE;
+
+	private static final OAuth2AuthorizationCode AUTHORIZATION_CODE = new OAuth2AuthorizationCode("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
+	public void setup() {
+		this.authorizationService = new InMemoryOAuth2AuthorizationService();
+	}
+
+	@Test
+	public void constructorVarargsWhenAuthorizationNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new InMemoryOAuth2AuthorizationService((OAuth2Authorization) null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorization cannot be null");
+	}
+
+	@Test
+	public void constructorListWhenAuthorizationsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new InMemoryOAuth2AuthorizationService((List<OAuth2Authorization>) null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizations cannot be null");
+	}
+
+	@Test
+	public void constructorWhenDuplicateAuthorizationsThenThrowIllegalArgumentException() {
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+
+		assertThatThrownBy(() -> new InMemoryOAuth2AuthorizationService(authorization, authorization))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("The authorization must be unique. Found duplicate identifier: id");
+	}
+
+	@Test
+	public void saveWhenAuthorizationNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authorizationService.save(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorization cannot be null");
+	}
+
+	@Test
+	public void saveWhenAuthorizationNewThenSaved() {
+		OAuth2Authorization expectedAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		this.authorizationService.save(expectedAuthorization);
+
+		OAuth2Authorization authorization = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(expectedAuthorization);
+	}
+
+	// gh-222
+	@Test
+	public void saveWhenAuthorizationExistsThenUpdated() {
+		OAuth2Authorization originalAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		this.authorizationService.save(originalAuthorization);
+
+		OAuth2Authorization authorization = this.authorizationService.findById(originalAuthorization.getId());
+		assertThat(authorization).isEqualTo(originalAuthorization);
+
+		OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
+			.attribute("custom-name-1", "custom-value-1")
+			.build();
+		this.authorizationService.save(updatedAuthorization);
+
+		authorization = this.authorizationService.findById(updatedAuthorization.getId());
+		assertThat(authorization).isEqualTo(updatedAuthorization);
+		assertThat(authorization).isNotEqualTo(originalAuthorization);
+	}
+
+	@Test
+	public void saveWhenInitializedAuthorizationsReachMaxThenOldestRemoved() {
+		int maxInitializedAuthorizations = 5;
+		InMemoryOAuth2AuthorizationService authorizationService = new InMemoryOAuth2AuthorizationService(
+				maxInitializedAuthorizations);
+
+		OAuth2Authorization initialAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID + "-initial")
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.attribute(OAuth2ParameterNames.STATE, "state-initial")
+			.build();
+		authorizationService.save(initialAuthorization);
+
+		OAuth2Authorization authorization = authorizationService.findById(initialAuthorization.getId());
+		assertThat(authorization).isEqualTo(initialAuthorization);
+		authorization = authorizationService.findByToken(initialAuthorization.getAttribute(OAuth2ParameterNames.STATE),
+				STATE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(initialAuthorization);
+
+		for (int i = 0; i < maxInitializedAuthorizations; i++) {
+			authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+				.id(ID + "-" + i)
+				.principalName(PRINCIPAL_NAME)
+				.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+				.attribute(OAuth2ParameterNames.STATE, "state-" + i)
+				.build();
+			authorizationService.save(authorization);
+		}
+
+		authorization = authorizationService.findById(initialAuthorization.getId());
+		assertThat(authorization).isNull();
+		authorization = authorizationService.findByToken(initialAuthorization.getAttribute(OAuth2ParameterNames.STATE),
+				STATE_TOKEN_TYPE);
+		assertThat(authorization).isNull();
+	}
+
+	@Test
+	public void removeWhenAuthorizationNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authorizationService.remove(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorization cannot be null");
+	}
+
+	@Test
+	public void removeWhenAuthorizationProvidedThenRemoved() {
+		OAuth2Authorization expectedAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+
+		this.authorizationService.save(expectedAuthorization);
+		OAuth2Authorization authorization = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(expectedAuthorization);
+
+		this.authorizationService.remove(expectedAuthorization);
+		authorization = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isNull();
+	}
+
+	@Test
+	public void findByIdWhenIdNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authorizationService.findById(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("id cannot be empty");
+	}
+
+	@Test
+	public void findByTokenWhenTokenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authorizationService.findByToken(null, AUTHORIZATION_CODE_TOKEN_TYPE))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("token cannot be empty");
+	}
+
+	@Test
+	public void findByTokenWhenStateExistsThenFound() {
+		String state = "state";
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.attribute(OAuth2ParameterNames.STATE, state)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(state, STATE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(state, null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenAuthorizationCodeExistsThenFound() {
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenAccessTokenExistsThenFound() {
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				Instant.now().minusSeconds(60), Instant.now());
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.accessToken(accessToken)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(accessToken.getTokenValue(),
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(accessToken.getTokenValue(), null);
+		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());
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.refreshToken(refreshToken)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(refreshToken.getTokenValue(),
+				OAuth2TokenType.REFRESH_TOKEN);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(refreshToken.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenWrongTokenTypeThenNotFound() {
+		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now());
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.refreshToken(refreshToken)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(refreshToken.getTokenValue(),
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(result).isNull();
+	}
+
+	@Test
+	public void findByTokenWhenTokenDoesNotExistThenNull() {
+		OAuth2Authorization result = this.authorizationService.findByToken("access-token",
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(result).isNull();
+	}
+
+}

+ 369 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationConsentServiceTests.java

@@ -0,0 +1,369 @@
+/*
+ * Copyright 2020-2022 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;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Types;
+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.dao.DataRetrievalFailureException;
+import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementSetter;
+import org.springframework.jdbc.core.SqlParameterValue;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+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.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link JdbcOAuth2AuthorizationConsentService}.
+ *
+ * @author Ovidiu Popa
+ */
+public class JdbcOAuth2AuthorizationConsentServiceTests {
+
+	private static final String OAUTH2_AUTHORIZATION_CONSENT_SCHEMA_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql";
+
+	private static final String CUSTOM_OAUTH2_AUTHORIZATION_CONSENT_SCHEMA_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-consent-schema.sql";
+
+	private static final String PRINCIPAL_NAME = "principal-name";
+
+	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
+
+	private static final OAuth2AuthorizationConsent AUTHORIZATION_CONSENT = OAuth2AuthorizationConsent
+		.withId(REGISTERED_CLIENT.getId(), PRINCIPAL_NAME)
+		.authority(new SimpleGrantedAuthority("SCOPE_scope1"))
+		.authority(new SimpleGrantedAuthority("SCOPE_scope2"))
+		.authority(new SimpleGrantedAuthority("SCOPE_scope3"))
+		.authority(new SimpleGrantedAuthority("authority-a"))
+		.authority(new SimpleGrantedAuthority("authority-b"))
+		.build();
+
+	private EmbeddedDatabase db;
+
+	private JdbcOperations jdbcOperations;
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private JdbcOAuth2AuthorizationConsentService authorizationConsentService;
+
+	@BeforeEach
+	public void setUp() {
+		this.db = createDb();
+		this.jdbcOperations = new JdbcTemplate(this.db);
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationConsentService = new JdbcOAuth2AuthorizationConsentService(this.jdbcOperations,
+				this.registeredClientRepository);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.db.shutdown();
+	}
+
+	@Test
+	public void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new JdbcOAuth2AuthorizationConsentService(null, this.registeredClientRepository))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("jdbcOperations cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new JdbcOAuth2AuthorizationConsentService(this.jdbcOperations, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("registeredClientRepository cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthorizationConsentRowMapperWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationConsentService.setAuthorizationConsentRowMapper(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationConsentRowMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthorizationConsentParametersMapperWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationConsentService.setAuthorizationConsentParametersMapper(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationConsentParametersMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void saveWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authorizationConsentService.save(null))
+				.withMessage("authorizationConsent cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void saveWhenAuthorizationConsentNewThenSaved() {
+		OAuth2AuthorizationConsent expectedAuthorizationConsent = OAuth2AuthorizationConsent
+			.withId("new-client", "new-principal")
+			.authority(new SimpleGrantedAuthority("new.authority"))
+			.build();
+
+		RegisteredClient newRegisteredClient = TestRegisteredClients.registeredClient().id("new-client").build();
+
+		given(this.registeredClientRepository.findById(eq(newRegisteredClient.getId())))
+			.willReturn(newRegisteredClient);
+
+		this.authorizationConsentService.save(expectedAuthorizationConsent);
+
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService.findById("new-client",
+				"new-principal");
+		assertThat(authorizationConsent).isEqualTo(expectedAuthorizationConsent);
+	}
+
+	@Test
+	public void saveWhenAuthorizationConsentExistsThenUpdated() {
+		OAuth2AuthorizationConsent expectedAuthorizationConsent = OAuth2AuthorizationConsent.from(AUTHORIZATION_CONSENT)
+			.authority(new SimpleGrantedAuthority("new.authority"))
+			.build();
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+
+		this.authorizationConsentService.save(expectedAuthorizationConsent);
+
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService
+			.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(), AUTHORIZATION_CONSENT.getPrincipalName());
+		assertThat(authorizationConsent).isEqualTo(expectedAuthorizationConsent);
+		assertThat(authorizationConsent).isNotEqualTo(AUTHORIZATION_CONSENT);
+	}
+
+	@Test
+	public void saveLoadAuthorizationConsentWhenCustomStrategiesSetThenCalled() throws Exception {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+
+		JdbcOAuth2AuthorizationConsentService.OAuth2AuthorizationConsentRowMapper authorizationConsentRowMapper = spy(
+				new JdbcOAuth2AuthorizationConsentService.OAuth2AuthorizationConsentRowMapper(
+						this.registeredClientRepository));
+		this.authorizationConsentService.setAuthorizationConsentRowMapper(authorizationConsentRowMapper);
+		JdbcOAuth2AuthorizationConsentService.OAuth2AuthorizationConsentParametersMapper authorizationConsentParametersMapper = spy(
+				new JdbcOAuth2AuthorizationConsentService.OAuth2AuthorizationConsentParametersMapper());
+		this.authorizationConsentService.setAuthorizationConsentParametersMapper(authorizationConsentParametersMapper);
+
+		this.authorizationConsentService.save(AUTHORIZATION_CONSENT);
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService
+			.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(), AUTHORIZATION_CONSENT.getPrincipalName());
+		assertThat(authorizationConsent).isEqualTo(AUTHORIZATION_CONSENT);
+		verify(authorizationConsentRowMapper).mapRow(any(), anyInt());
+		verify(authorizationConsentParametersMapper).apply(any());
+	}
+
+	@Test
+	public void removeWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.authorizationConsentService.remove(null))
+			.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void removeWhenAuthorizationConsentProvidedThenRemoved() {
+		this.authorizationConsentService.remove(AUTHORIZATION_CONSENT);
+		assertThat(this.authorizationConsentService.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(),
+				AUTHORIZATION_CONSENT.getPrincipalName()))
+			.isNull();
+	}
+
+	@Test
+	public void findByIdWhenRegisteredClientIdNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authorizationConsentService.findById(null, "some-user"))
+			.withMessage("registeredClientId cannot be empty");
+	}
+
+	@Test
+	public void findByIdWhenPrincipalNameNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authorizationConsentService.findById("some-client", null))
+			.withMessage("principalName cannot be empty");
+	}
+
+	@Test
+	public void findByIdWhenAuthorizationConsentExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+
+		this.authorizationConsentService.save(AUTHORIZATION_CONSENT);
+		OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService
+			.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(), AUTHORIZATION_CONSENT.getPrincipalName());
+		assertThat(authorizationConsent).isNotNull();
+	}
+
+	@Test
+	public void findByIdWhenAuthorizationConsentDoesNotExistThenNull() {
+		this.authorizationConsentService.save(AUTHORIZATION_CONSENT);
+		assertThat(this.authorizationConsentService.findById("unknown-client", PRINCIPAL_NAME)).isNull();
+		assertThat(this.authorizationConsentService.findById(REGISTERED_CLIENT.getId(), "unknown-user")).isNull();
+	}
+
+	@Test
+	public void tableDefinitionWhenCustomThenAbleToOverride() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+
+		EmbeddedDatabase db = createDb(CUSTOM_OAUTH2_AUTHORIZATION_CONSENT_SCHEMA_SQL_RESOURCE);
+		OAuth2AuthorizationConsentService authorizationConsentService = new CustomJdbcOAuth2AuthorizationConsentService(
+				new JdbcTemplate(db), this.registeredClientRepository);
+		authorizationConsentService.save(AUTHORIZATION_CONSENT);
+		OAuth2AuthorizationConsent foundAuthorizationConsent1 = authorizationConsentService
+			.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(), AUTHORIZATION_CONSENT.getPrincipalName());
+		assertThat(foundAuthorizationConsent1).isEqualTo(AUTHORIZATION_CONSENT);
+		authorizationConsentService.remove(AUTHORIZATION_CONSENT);
+		OAuth2AuthorizationConsent foundAuthorizationConsent2 = authorizationConsentService
+			.findById(AUTHORIZATION_CONSENT.getRegisteredClientId(), AUTHORIZATION_CONSENT.getPrincipalName());
+		assertThat(foundAuthorizationConsent2).isNull();
+		db.shutdown();
+	}
+
+	private static EmbeddedDatabase createDb() {
+		return createDb(OAUTH2_AUTHORIZATION_CONSENT_SCHEMA_SQL_RESOURCE);
+	}
+
+	private static EmbeddedDatabase createDb(String schema) {
+		// @formatter:off
+		return new EmbeddedDatabaseBuilder()
+				.generateUniqueName(true)
+				.setType(EmbeddedDatabaseType.HSQL)
+				.setScriptEncoding("UTF-8")
+				.addScript(schema)
+				.build();
+		// @formatter:on
+	}
+
+	private static final class CustomJdbcOAuth2AuthorizationConsentService
+			extends JdbcOAuth2AuthorizationConsentService {
+
+		// @formatter:off
+		private static final String COLUMN_NAMES = "registeredClientId, "
+				+ "principalName, "
+				+ "authorities";
+		// @formatter:on
+
+		private static final String TABLE_NAME = "oauth2AuthorizationConsent";
+
+		private static final String PK_FILTER = "registeredClientId = ? AND principalName = ?";
+
+		// @formatter:off
+		private static final String LOAD_AUTHORIZATION_CONSENT_SQL = "SELECT " + COLUMN_NAMES
+				+ " FROM " + TABLE_NAME
+				+ " WHERE " + PK_FILTER;
+		// @formatter:on
+
+		// @formatter:off
+		private static final String SAVE_AUTHORIZATION_CONSENT_SQL = "INSERT INTO " + TABLE_NAME
+				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?)";
+		// @formatter:on
+
+		private static final String REMOVE_AUTHORIZATION_CONSENT_SQL = "DELETE FROM " + TABLE_NAME + " WHERE "
+				+ PK_FILTER;
+
+		private CustomJdbcOAuth2AuthorizationConsentService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			super(jdbcOperations, registeredClientRepository);
+			setAuthorizationConsentRowMapper(new CustomOAuth2AuthorizationConsentRowMapper(registeredClientRepository));
+		}
+
+		@Override
+		public void save(OAuth2AuthorizationConsent authorizationConsent) {
+			List<SqlParameterValue> parameters = getAuthorizationConsentParametersMapper().apply(authorizationConsent);
+			PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
+			getJdbcOperations().update(SAVE_AUTHORIZATION_CONSENT_SQL, pss);
+		}
+
+		@Override
+		public void remove(OAuth2AuthorizationConsent authorizationConsent) {
+			SqlParameterValue[] parameters = new SqlParameterValue[] {
+					new SqlParameterValue(Types.VARCHAR, authorizationConsent.getRegisteredClientId()),
+					new SqlParameterValue(Types.VARCHAR, authorizationConsent.getPrincipalName()) };
+			PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
+			getJdbcOperations().update(REMOVE_AUTHORIZATION_CONSENT_SQL, pss);
+		}
+
+		@Override
+		public OAuth2AuthorizationConsent findById(String registeredClientId, String principalName) {
+			SqlParameterValue[] parameters = new SqlParameterValue[] {
+					new SqlParameterValue(Types.VARCHAR, registeredClientId),
+					new SqlParameterValue(Types.VARCHAR, principalName) };
+			PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
+			List<OAuth2AuthorizationConsent> result = getJdbcOperations().query(LOAD_AUTHORIZATION_CONSENT_SQL, pss,
+					getAuthorizationConsentRowMapper());
+			return !result.isEmpty() ? result.get(0) : null;
+		}
+
+		private static final class CustomOAuth2AuthorizationConsentRowMapper
+				extends JdbcOAuth2AuthorizationConsentService.OAuth2AuthorizationConsentRowMapper {
+
+			private CustomOAuth2AuthorizationConsentRowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+			}
+
+			@Override
+			public OAuth2AuthorizationConsent mapRow(ResultSet rs, int rowNum) throws SQLException {
+				String registeredClientId = rs.getString("registeredClientId");
+				RegisteredClient registeredClient = getRegisteredClientRepository().findById(registeredClientId);
+				if (registeredClient == null) {
+					throw new DataRetrievalFailureException("The RegisteredClient with id '" + registeredClientId
+							+ "' was not found in the RegisteredClientRepository.");
+				}
+
+				String principalName = rs.getString("principalName");
+
+				OAuth2AuthorizationConsent.Builder builder = OAuth2AuthorizationConsent.withId(registeredClientId,
+						principalName);
+				String authorizationConsentAuthorities = rs.getString("authorities");
+				if (authorizationConsentAuthorities != null) {
+					for (String authority : StringUtils.commaDelimitedListToSet(authorizationConsentAuthorities)) {
+						builder.authority(new SimpleGrantedAuthority(authority));
+					}
+				}
+				return builder.build();
+			}
+
+		}
+
+	}
+
+}

+ 875 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/JdbcOAuth2AuthorizationServiceTests.java

@@ -0,0 +1,875 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.sql.Types;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.dao.DataRetrievalFailureException;
+import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementSetter;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.SqlParameterValue;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+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;
+import org.springframework.util.CollectionUtils;
+import org.springframework.util.StringUtils;
+
+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.anyInt;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link JdbcOAuth2AuthorizationService}.
+ *
+ * @author Ovidiu Popa
+ * @author Steve Riesenberg
+ */
+public class JdbcOAuth2AuthorizationServiceTests {
+
+	private static final String OAUTH2_AUTHORIZATION_SCHEMA_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql";
+
+	private static final String CUSTOM_OAUTH2_AUTHORIZATION_SCHEMA_SQL_RESOURCE = "org/springframework/security/oauth2/server/authorization/custom-oauth2-authorization-schema.sql";
+
+	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 OAuth2TokenType USER_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.USER_CODE);
+
+	private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
+
+	private static final String ID = "id";
+
+	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
+
+	private static final String PRINCIPAL_NAME = "principal";
+
+	private static final AuthorizationGrantType AUTHORIZATION_GRANT_TYPE = AuthorizationGrantType.AUTHORIZATION_CODE;
+
+	private static final OAuth2AuthorizationCode AUTHORIZATION_CODE = new OAuth2AuthorizationCode("code",
+			Instant.now().truncatedTo(ChronoUnit.MILLIS),
+			Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS));
+
+	private EmbeddedDatabase db;
+
+	private JdbcOperations jdbcOperations;
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private JdbcOAuth2AuthorizationService authorizationService;
+
+	@BeforeEach
+	public void setUp() {
+		this.db = createDb();
+		this.jdbcOperations = new JdbcTemplate(this.db);
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = new JdbcOAuth2AuthorizationService(this.jdbcOperations,
+				this.registeredClientRepository);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.db.shutdown();
+	}
+
+	@Test
+	public void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new JdbcOAuth2AuthorizationService(null, this.registeredClientRepository))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("jdbcOperations cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new JdbcOAuth2AuthorizationService(this.jdbcOperations, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("registeredClientRepository cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenLobHandlerIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new JdbcOAuth2AuthorizationService(this.jdbcOperations, this.registeredClientRepository, null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("lobHandler cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthorizationRowMapperWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.setAuthorizationRowMapper(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationRowMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthorizationParametersMapperWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.setAuthorizationParametersMapper(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorizationParametersMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void saveWhenAuthorizationNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.save(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorization cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void saveWhenAuthorizationNewThenSaved() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2Authorization expectedAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		this.authorizationService.save(expectedAuthorization);
+
+		OAuth2Authorization authorization = this.authorizationService.findById(ID);
+		assertThat(authorization).isEqualTo(expectedAuthorization);
+	}
+
+	@Test
+	public void saveWhenAuthorizationExistsThenUpdated() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2Authorization originalAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		this.authorizationService.save(originalAuthorization);
+
+		OAuth2Authorization authorization = this.authorizationService.findById(originalAuthorization.getId());
+		assertThat(authorization).isEqualTo(originalAuthorization);
+
+		OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
+			.attribute("custom-name-1", "custom-value-1")
+			.build();
+		this.authorizationService.save(updatedAuthorization);
+
+		authorization = this.authorizationService.findById(updatedAuthorization.getId());
+		assertThat(authorization).isEqualTo(updatedAuthorization);
+		assertThat(authorization).isNotEqualTo(originalAuthorization);
+	}
+
+	@Test
+	public void saveLoadAuthorizationWhenCustomStrategiesSetThenCalled() throws Exception {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2Authorization originalAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+
+		RowMapper<OAuth2Authorization> authorizationRowMapper = spy(
+				new JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper(this.registeredClientRepository));
+		this.authorizationService.setAuthorizationRowMapper(authorizationRowMapper);
+		Function<OAuth2Authorization, List<SqlParameterValue>> authorizationParametersMapper = spy(
+				new JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper());
+		this.authorizationService.setAuthorizationParametersMapper(authorizationParametersMapper);
+
+		this.authorizationService.save(originalAuthorization);
+		OAuth2Authorization authorization = this.authorizationService.findById(originalAuthorization.getId());
+		assertThat(authorization).isEqualTo(originalAuthorization);
+		verify(authorizationRowMapper).mapRow(any(), anyInt());
+		verify(authorizationParametersMapper).apply(any());
+	}
+
+	@Test
+	public void removeWhenAuthorizationNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.remove(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("authorization cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void removeWhenAuthorizationProvidedThenRemoved() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2Authorization expectedAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+
+		this.authorizationService.save(expectedAuthorization);
+		OAuth2Authorization authorization = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(expectedAuthorization);
+
+		this.authorizationService.remove(authorization);
+		authorization = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isNull();
+	}
+
+	@Test
+	public void findByIdWhenIdNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.findById(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("id cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByIdWhenIdEmptyThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.findById(" "))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("id cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByTokenWhenTokenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authorizationService.findByToken(null, AUTHORIZATION_CODE_TOKEN_TYPE))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("token cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByTokenWhenStateExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		String state = "state";
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.attribute(OAuth2ParameterNames.STATE, state)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(state, STATE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(state, null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenAuthorizationCodeExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(),
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(AUTHORIZATION_CODE.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenAccessTokenExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS),
+				Instant.now().truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.accessToken(accessToken)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(accessToken.getTokenValue(),
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(accessToken.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenIdTokenExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(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() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token",
+				Instant.now().truncatedTo(ChronoUnit.MILLIS),
+				Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.refreshToken(refreshToken)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(refreshToken.getTokenValue(),
+				OAuth2TokenType.REFRESH_TOKEN);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(refreshToken.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenDeviceCodeExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2DeviceCode deviceCode = new OAuth2DeviceCode("device-code", Instant.now().truncatedTo(ChronoUnit.MILLIS),
+				Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(deviceCode)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(deviceCode.getTokenValue(),
+				DEVICE_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(deviceCode.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenUserCodeExistsThenFound() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+		OAuth2UserCode userCode = new OAuth2UserCode("user-code", Instant.now().truncatedTo(ChronoUnit.MILLIS),
+				Instant.now().plus(5, ChronoUnit.MINUTES).truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(userCode)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(userCode.getTokenValue(),
+				USER_CODE_TOKEN_TYPE);
+		assertThat(authorization).isEqualTo(result);
+		result = this.authorizationService.findByToken(userCode.getTokenValue(), null);
+		assertThat(authorization).isEqualTo(result);
+	}
+
+	@Test
+	public void findByTokenWhenWrongTokenTypeThenNotFound() {
+		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token",
+				Instant.now().truncatedTo(ChronoUnit.MILLIS));
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.refreshToken(refreshToken)
+			.build();
+		this.authorizationService.save(authorization);
+
+		OAuth2Authorization result = this.authorizationService.findByToken(refreshToken.getTokenValue(),
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(result).isNull();
+	}
+
+	@Test
+	public void findByTokenWhenTokenDoesNotExistThenNull() {
+		OAuth2Authorization result = this.authorizationService.findByToken("access-token",
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(result).isNull();
+	}
+
+	@Test
+	public void tableDefinitionWhenCustomThenAbleToOverride() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+
+		EmbeddedDatabase db = createDb(CUSTOM_OAUTH2_AUTHORIZATION_SCHEMA_SQL_RESOURCE);
+		OAuth2AuthorizationService authorizationService = new CustomJdbcOAuth2AuthorizationService(new JdbcTemplate(db),
+				this.registeredClientRepository);
+		String state = "state";
+		OAuth2Authorization originalAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.attribute(OAuth2ParameterNames.STATE, state)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		authorizationService.save(originalAuthorization);
+		OAuth2Authorization foundAuthorization1 = authorizationService.findById(originalAuthorization.getId());
+		assertThat(foundAuthorization1).isEqualTo(originalAuthorization);
+		OAuth2Authorization foundAuthorization2 = authorizationService.findByToken(state, STATE_TOKEN_TYPE);
+		assertThat(foundAuthorization2).isEqualTo(originalAuthorization);
+		db.shutdown();
+	}
+
+	@Test
+	public void tableDefinitionWhenClobSqlTypeThenAuthorizationUpdated() {
+		given(this.registeredClientRepository.findById(eq(REGISTERED_CLIENT.getId()))).willReturn(REGISTERED_CLIENT);
+
+		EmbeddedDatabase db = createDb(OAUTH2_AUTHORIZATION_SCHEMA_CLOB_DATA_TYPE_SQL_RESOURCE);
+		OAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(new JdbcTemplate(db),
+				this.registeredClientRepository);
+		OAuth2Authorization originalAuthorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.build();
+		authorizationService.save(originalAuthorization);
+
+		OAuth2Authorization authorization = authorizationService.findById(originalAuthorization.getId());
+		assertThat(authorization).isEqualTo(originalAuthorization);
+
+		OAuth2Authorization updatedAuthorization = OAuth2Authorization.from(authorization)
+			.attribute("custom-name-1", "custom-value-1")
+			.build();
+		authorizationService.save(updatedAuthorization);
+
+		authorization = authorizationService.findById(updatedAuthorization.getId());
+		assertThat(authorization).isEqualTo(updatedAuthorization);
+		assertThat(authorization).isNotEqualTo(originalAuthorization);
+		db.shutdown();
+	}
+
+	private static EmbeddedDatabase createDb() {
+		return createDb(OAUTH2_AUTHORIZATION_SCHEMA_SQL_RESOURCE);
+	}
+
+	private static EmbeddedDatabase createDb(String schema) {
+		// @formatter:off
+		return new EmbeddedDatabaseBuilder()
+				.generateUniqueName(true)
+				.setType(EmbeddedDatabaseType.HSQL)
+				.setScriptEncoding("UTF-8")
+				.addScript(schema)
+				.build();
+		// @formatter:on
+	}
+
+	private static final class CustomJdbcOAuth2AuthorizationService extends JdbcOAuth2AuthorizationService {
+
+		// @formatter:off
+		private static final String COLUMN_NAMES = "id, "
+				+ "registeredClientId, "
+				+ "principalName, "
+				+ "authorizationGrantType, "
+				+ "authorizedScopes, "
+				+ "attributes, "
+				+ "state, "
+				+ "authorizationCodeValue, "
+				+ "authorizationCodeIssuedAt, "
+				+ "authorizationCodeExpiresAt,"
+				+ "authorizationCodeMetadata,"
+				+ "accessTokenValue,"
+				+ "accessTokenIssuedAt,"
+				+ "accessTokenExpiresAt,"
+				+ "accessTokenMetadata,"
+				+ "accessTokenType,"
+				+ "accessTokenScopes,"
+				+ "oidcIdTokenValue,"
+				+ "oidcIdTokenIssuedAt,"
+				+ "oidcIdTokenExpiresAt,"
+				+ "oidcIdTokenMetadata,"
+				+ "refreshTokenValue,"
+				+ "refreshTokenIssuedAt,"
+				+ "refreshTokenExpiresAt,"
+				+ "refreshTokenMetadata,"
+				+ "userCodeValue,"
+				+ "userCodeIssuedAt,"
+				+ "userCodeExpiresAt,"
+				+ "userCodeMetadata,"
+				+ "deviceCodeValue,"
+				+ "deviceCodeIssuedAt,"
+				+ "deviceCodeExpiresAt,"
+				+ "deviceCodeMetadata";
+		// @formatter:on
+
+		private static final String TABLE_NAME = "oauth2Authorization";
+
+		private static final String PK_FILTER = "id = ?";
+
+		private static final String UNKNOWN_TOKEN_TYPE_FILTER = "state = ? OR authorizationCodeValue = ? OR "
+				+ "accessTokenValue = ? OR oidcIdTokenValue = ? OR refreshTokenValue = ? OR userCodeValue = ? OR "
+				+ "deviceCodeValue = ?";
+
+		// @formatter:off
+		private static final String LOAD_AUTHORIZATION_SQL = "SELECT " + COLUMN_NAMES
+				+ " FROM " + TABLE_NAME
+				+ " WHERE ";
+		// @formatter:on
+
+		// @formatter:off
+		private static final String SAVE_AUTHORIZATION_SQL = "INSERT INTO " + TABLE_NAME
+				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+		// @formatter:on
+
+		private static final String REMOVE_AUTHORIZATION_SQL = "DELETE FROM " + TABLE_NAME + " WHERE " + PK_FILTER;
+
+		private CustomJdbcOAuth2AuthorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			super(jdbcOperations, registeredClientRepository);
+			setAuthorizationRowMapper(new CustomOAuth2AuthorizationRowMapper(registeredClientRepository));
+			setAuthorizationParametersMapper(new CustomOAuth2AuthorizationParametersMapper());
+		}
+
+		@Override
+		public void save(OAuth2Authorization authorization) {
+			List<SqlParameterValue> parameters = getAuthorizationParametersMapper().apply(authorization);
+			PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
+			getJdbcOperations().update(SAVE_AUTHORIZATION_SQL, pss);
+		}
+
+		@Override
+		public void remove(OAuth2Authorization authorization) {
+			SqlParameterValue[] parameters = new SqlParameterValue[] {
+					new SqlParameterValue(Types.VARCHAR, authorization.getId()) };
+			PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters);
+			getJdbcOperations().update(REMOVE_AUTHORIZATION_SQL, pss);
+		}
+
+		@Override
+		public OAuth2Authorization findById(String id) {
+			return findBy(PK_FILTER, id);
+		}
+
+		@Override
+		public OAuth2Authorization findByToken(String token, OAuth2TokenType tokenType) {
+			return findBy(UNKNOWN_TOKEN_TYPE_FILTER, token, token, token, token, token, token, token);
+		}
+
+		private OAuth2Authorization findBy(String filter, Object... args) {
+			List<OAuth2Authorization> result = getJdbcOperations().query(LOAD_AUTHORIZATION_SQL + filter,
+					getAuthorizationRowMapper(), args);
+			return !result.isEmpty() ? result.get(0) : null;
+		}
+
+		private static final class CustomOAuth2AuthorizationRowMapper
+				extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			private CustomOAuth2AuthorizationRowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+			}
+
+			@Override
+			@SuppressWarnings("unchecked")
+			public OAuth2Authorization mapRow(ResultSet rs, int rowNum) throws SQLException {
+				String registeredClientId = rs.getString("registeredClientId");
+				RegisteredClient registeredClient = getRegisteredClientRepository().findById(registeredClientId);
+				if (registeredClient == null) {
+					throw new DataRetrievalFailureException("The RegisteredClient with id '" + registeredClientId
+							+ "' was not found in the RegisteredClientRepository.");
+				}
+
+				OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient);
+				String id = rs.getString("id");
+				String principalName = rs.getString("principalName");
+				String authorizationGrantType = rs.getString("authorizationGrantType");
+				Set<String> authorizedScopes = Collections.emptySet();
+				String authorizedScopesString = rs.getString("authorizedScopes");
+				if (authorizedScopesString != null) {
+					authorizedScopes = StringUtils.commaDelimitedListToSet(authorizedScopesString);
+				}
+				Map<String, Object> attributes = parseMap(rs.getString("attributes"));
+
+				builder.id(id)
+					.principalName(principalName)
+					.authorizationGrantType(new AuthorizationGrantType(authorizationGrantType))
+					.authorizedScopes(authorizedScopes)
+					.attributes((attrs) -> attrs.putAll(attributes));
+
+				String state = rs.getString("state");
+				if (StringUtils.hasText(state)) {
+					builder.attribute(OAuth2ParameterNames.STATE, state);
+				}
+
+				String tokenValue = rs.getString("authorizationCodeValue");
+				Instant tokenIssuedAt;
+				Instant tokenExpiresAt;
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("authorizationCodeIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("authorizationCodeExpiresAt").toInstant();
+					Map<String, Object> authorizationCodeMetadata = parseMap(rs.getString("authorizationCodeMetadata"));
+
+					OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(tokenValue, tokenIssuedAt,
+							tokenExpiresAt);
+					builder.token(authorizationCode, (metadata) -> metadata.putAll(authorizationCodeMetadata));
+				}
+
+				tokenValue = rs.getString("accessTokenValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("accessTokenIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("accessTokenExpiresAt").toInstant();
+					Map<String, Object> accessTokenMetadata = parseMap(rs.getString("accessTokenMetadata"));
+					OAuth2AccessToken.TokenType tokenType = null;
+					if (OAuth2AccessToken.TokenType.BEARER.getValue()
+						.equalsIgnoreCase(rs.getString("accessTokenType"))) {
+						tokenType = OAuth2AccessToken.TokenType.BEARER;
+					}
+
+					Set<String> scopes = Collections.emptySet();
+					String accessTokenScopes = rs.getString("accessTokenScopes");
+					if (accessTokenScopes != null) {
+						scopes = StringUtils.commaDelimitedListToSet(accessTokenScopes);
+					}
+					OAuth2AccessToken accessToken = new OAuth2AccessToken(tokenType, tokenValue, tokenIssuedAt,
+							tokenExpiresAt, scopes);
+					builder.token(accessToken, (metadata) -> metadata.putAll(accessTokenMetadata));
+				}
+
+				tokenValue = rs.getString("oidcIdTokenValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("oidcIdTokenIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("oidcIdTokenExpiresAt").toInstant();
+					Map<String, Object> oidcTokenMetadata = parseMap(rs.getString("oidcIdTokenMetadata"));
+
+					OidcIdToken oidcToken = new OidcIdToken(tokenValue, tokenIssuedAt, tokenExpiresAt,
+							(Map<String, Object>) oidcTokenMetadata
+								.get(OAuth2Authorization.Token.CLAIMS_METADATA_NAME));
+					builder.token(oidcToken, (metadata) -> metadata.putAll(oidcTokenMetadata));
+				}
+
+				tokenValue = rs.getString("refreshTokenValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("refreshTokenIssuedAt").toInstant();
+					tokenExpiresAt = null;
+					Timestamp refreshTokenExpiresAt = rs.getTimestamp("refreshTokenExpiresAt");
+					if (refreshTokenExpiresAt != null) {
+						tokenExpiresAt = refreshTokenExpiresAt.toInstant();
+					}
+					Map<String, Object> refreshTokenMetadata = parseMap(rs.getString("refreshTokenMetadata"));
+
+					OAuth2RefreshToken refreshToken = new OAuth2RefreshToken(tokenValue, tokenIssuedAt, tokenExpiresAt);
+					builder.token(refreshToken, (metadata) -> metadata.putAll(refreshTokenMetadata));
+				}
+
+				tokenValue = rs.getString("userCodeValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("userCodeIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("userCodeExpiresAt").toInstant();
+					Map<String, Object> userCodeMetadata = parseMap(rs.getString("userCodeMetadata"));
+
+					OAuth2UserCode userCode = new OAuth2UserCode(tokenValue, tokenIssuedAt, tokenExpiresAt);
+					builder.token(userCode, (metadata) -> metadata.putAll(userCodeMetadata));
+				}
+
+				tokenValue = rs.getString("deviceCodeValue");
+				if (tokenValue != null) {
+					tokenIssuedAt = rs.getTimestamp("deviceCodeIssuedAt").toInstant();
+					tokenExpiresAt = rs.getTimestamp("deviceCodeExpiresAt").toInstant();
+					Map<String, Object> deviceCodeMetadata = parseMap(rs.getString("deviceCodeMetadata"));
+
+					OAuth2UserCode deviceCode = new OAuth2UserCode(tokenValue, tokenIssuedAt, tokenExpiresAt);
+					builder.token(deviceCode, (metadata) -> metadata.putAll(deviceCodeMetadata));
+				}
+
+				return builder.build();
+			}
+
+			private Map<String, Object> parseMap(String data) {
+				try {
+					return getObjectMapper().readValue(data, new TypeReference<>() {
+					});
+				}
+				catch (Exception ex) {
+					throw new IllegalArgumentException(ex.getMessage(), ex);
+				}
+			}
+
+		}
+
+		private static final class CustomOAuth2AuthorizationParametersMapper
+				extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			@Override
+			public List<SqlParameterValue> apply(OAuth2Authorization authorization) {
+				List<SqlParameterValue> parameters = new ArrayList<>();
+				parameters.add(new SqlParameterValue(Types.VARCHAR, authorization.getId()));
+				parameters.add(new SqlParameterValue(Types.VARCHAR, authorization.getRegisteredClientId()));
+				parameters.add(new SqlParameterValue(Types.VARCHAR, authorization.getPrincipalName()));
+				parameters
+					.add(new SqlParameterValue(Types.VARCHAR, authorization.getAuthorizationGrantType().getValue()));
+
+				String authorizedScopes = null;
+				if (!CollectionUtils.isEmpty(authorization.getAuthorizedScopes())) {
+					authorizedScopes = StringUtils.collectionToDelimitedString(authorization.getAuthorizedScopes(),
+							",");
+				}
+				parameters.add(new SqlParameterValue(Types.VARCHAR, authorizedScopes));
+
+				String attributes = writeMap(authorization.getAttributes());
+				parameters.add(new SqlParameterValue(Types.VARCHAR, attributes));
+
+				String state = null;
+				String authorizationState = authorization.getAttribute(OAuth2ParameterNames.STATE);
+				if (StringUtils.hasText(authorizationState)) {
+					state = authorizationState;
+				}
+				parameters.add(new SqlParameterValue(Types.VARCHAR, state));
+
+				OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
+					.getToken(OAuth2AuthorizationCode.class);
+				List<SqlParameterValue> authorizationCodeSqlParameters = toSqlParameterList(authorizationCode);
+				parameters.addAll(authorizationCodeSqlParameters);
+
+				OAuth2Authorization.Token<OAuth2AccessToken> accessToken = authorization
+					.getToken(OAuth2AccessToken.class);
+				List<SqlParameterValue> accessTokenSqlParameters = toSqlParameterList(accessToken);
+				parameters.addAll(accessTokenSqlParameters);
+				String accessTokenType = null;
+				String accessTokenScopes = null;
+				if (accessToken != null) {
+					accessTokenType = accessToken.getToken().getTokenType().getValue();
+					if (!CollectionUtils.isEmpty(accessToken.getToken().getScopes())) {
+						accessTokenScopes = StringUtils.collectionToDelimitedString(accessToken.getToken().getScopes(),
+								",");
+					}
+				}
+				parameters.add(new SqlParameterValue(Types.VARCHAR, accessTokenType));
+				parameters.add(new SqlParameterValue(Types.VARCHAR, accessTokenScopes));
+
+				OAuth2Authorization.Token<OidcIdToken> oidcIdToken = authorization.getToken(OidcIdToken.class);
+				List<SqlParameterValue> oidcIdTokenSqlParameters = toSqlParameterList(oidcIdToken);
+				parameters.addAll(oidcIdTokenSqlParameters);
+
+				OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = authorization.getRefreshToken();
+				List<SqlParameterValue> refreshTokenSqlParameters = toSqlParameterList(refreshToken);
+				parameters.addAll(refreshTokenSqlParameters);
+
+				OAuth2Authorization.Token<OAuth2UserCode> userCode = authorization.getToken(OAuth2UserCode.class);
+				List<SqlParameterValue> userCodeSqlParameters = toSqlParameterList(userCode);
+				parameters.addAll(userCodeSqlParameters);
+
+				OAuth2Authorization.Token<OAuth2DeviceCode> deviceCode = authorization.getToken(OAuth2DeviceCode.class);
+				List<SqlParameterValue> deviceCodeSqlParameters = toSqlParameterList(deviceCode);
+				parameters.addAll(deviceCodeSqlParameters);
+
+				return parameters;
+			}
+
+			private <T extends OAuth2Token> List<SqlParameterValue> toSqlParameterList(
+					OAuth2Authorization.Token<T> token) {
+				List<SqlParameterValue> parameters = new ArrayList<>();
+				String tokenValue = null;
+				Timestamp tokenIssuedAt = null;
+				Timestamp tokenExpiresAt = null;
+				String metadata = null;
+				if (token != null) {
+					tokenValue = token.getToken().getTokenValue();
+					if (token.getToken().getIssuedAt() != null) {
+						tokenIssuedAt = Timestamp.from(token.getToken().getIssuedAt());
+					}
+					if (token.getToken().getExpiresAt() != null) {
+						tokenExpiresAt = Timestamp.from(token.getToken().getExpiresAt());
+					}
+					metadata = writeMap(token.getMetadata());
+				}
+				parameters.add(new SqlParameterValue(Types.VARCHAR, tokenValue));
+				parameters.add(new SqlParameterValue(Types.TIMESTAMP, tokenIssuedAt));
+				parameters.add(new SqlParameterValue(Types.TIMESTAMP, tokenExpiresAt));
+				parameters.add(new SqlParameterValue(Types.VARCHAR, metadata));
+				return parameters;
+			}
+
+			private String writeMap(Map<String, Object> data) {
+				try {
+					return getObjectMapper().writeValueAsString(data);
+				}
+				catch (Exception ex) {
+					throw new IllegalArgumentException(ex.getMessage(), ex);
+				}
+			}
+
+		}
+
+	}
+
+}

+ 105 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationConsentTests.java

@@ -0,0 +1,105 @@
+/*
+ * Copyright 2020-2022 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;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OAuth2AuthorizationConsent}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OAuth2AuthorizationConsentTests {
+
+	@Test
+	public void fromWhenAuthorizationConsentNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OAuth2AuthorizationConsent.from(null))
+			.withMessage("authorizationConsent cannot be null");
+	}
+
+	@Test
+	public void withIdWhenRegisteredClientIdNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OAuth2AuthorizationConsent.withId(null, "some-user"))
+			.withMessage("registeredClientId cannot be empty");
+	}
+
+	@Test
+	public void withIdWhenPrincipalNameNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OAuth2AuthorizationConsent.withId("some-client", null))
+			.withMessage("principalName cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAuthoritiesEmptyThenThrowIllegalArgumentException() {
+		OAuth2AuthorizationConsent.Builder builder = OAuth2AuthorizationConsent.withId("some-client", "some-user");
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("authorities cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAllAttributesAreProvidedThenAllAttributesAreSet() {
+		OAuth2AuthorizationConsent authorizationConsent = OAuth2AuthorizationConsent.withId("some-client", "some-user")
+			.scope("resource.read")
+			.scope("resource.write")
+			.authority(new SimpleGrantedAuthority("CLAIM_email"))
+			.build();
+
+		assertThat(authorizationConsent.getRegisteredClientId()).isEqualTo("some-client");
+		assertThat(authorizationConsent.getPrincipalName()).isEqualTo("some-user");
+		assertThat(authorizationConsent.getScopes()).containsExactlyInAnyOrder("resource.read", "resource.write");
+		assertThat(authorizationConsent.getAuthorities()).containsExactlyInAnyOrder(
+				new SimpleGrantedAuthority("SCOPE_resource.read"), new SimpleGrantedAuthority("SCOPE_resource.write"),
+				new SimpleGrantedAuthority("CLAIM_email"));
+	}
+
+	@Test
+	public void fromWhenAuthorizationConsentProvidedThenCopied() {
+		OAuth2AuthorizationConsent previousAuthorizationConsent = OAuth2AuthorizationConsent
+			.withId("some-client", "some-principal")
+			.scope("first.scope")
+			.scope("second.scope")
+			.authority(new SimpleGrantedAuthority("CLAIM_email"))
+			.build();
+
+		OAuth2AuthorizationConsent authorizationConsent = OAuth2AuthorizationConsent.from(previousAuthorizationConsent)
+			.build();
+
+		assertThat(authorizationConsent.getRegisteredClientId()).isEqualTo("some-client");
+		assertThat(authorizationConsent.getPrincipalName()).isEqualTo("some-principal");
+		assertThat(authorizationConsent.getAuthorities()).containsExactlyInAnyOrder(
+				new SimpleGrantedAuthority("SCOPE_first.scope"), new SimpleGrantedAuthority("SCOPE_second.scope"),
+				new SimpleGrantedAuthority("CLAIM_email"));
+	}
+
+	@Test
+	public void authoritiesThenCustomizesAuthorities() {
+		OAuth2AuthorizationConsent authorizationConsent = OAuth2AuthorizationConsent.withId("some-client", "some-user")
+			.authority(new SimpleGrantedAuthority("some.authority"))
+			.authorities((authorities) -> {
+				authorities.clear();
+				authorities.add(new SimpleGrantedAuthority("other.authority"));
+			})
+			.build();
+
+		assertThat(authorizationConsent.getAuthorities())
+			.containsExactly(new SimpleGrantedAuthority("other.authority"));
+	}
+
+}

+ 640 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationServerMetadataTests.java

@@ -0,0 +1,640 @@
+/*
+ * Copyright 2020-2025 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;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadata.Builder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OAuth2AuthorizationServerMetadata}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OAuth2AuthorizationServerMetadataTests {
+
+	// @formatter:off
+	private final Builder minimalBuilder =
+			OAuth2AuthorizationServerMetadata.builder()
+					.issuer("https://example.com")
+					.authorizationEndpoint("https://example.com/oauth2/authorize")
+					.tokenEndpoint("https://example.com/oauth2/token")
+					.responseType("code");
+	// @formatter:on
+
+	@Test
+	public void buildWhenAllClaimsProvidedThenCreated() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.pushedAuthorizationRequestEndpoint("https://example.com/oauth2/par")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.jwkSetUrl("https://example.com/oauth2/jwks")
+			.scope("openid")
+			.responseType("code")
+			.grantType("authorization_code")
+			.grantType("client_credentials")
+			.tokenRevocationEndpoint("https://example.com/oauth2/revoke")
+			.tokenRevocationEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.tokenIntrospectionEndpoint("https://example.com/oauth2/introspect")
+			.tokenIntrospectionEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.codeChallengeMethod("S256")
+			.tlsClientCertificateBoundAccessTokens(true)
+			.dPoPSigningAlgorithm(JwsAlgorithms.RS256)
+			.dPoPSigningAlgorithm(JwsAlgorithms.ES256)
+			.claim("a-claim", "a-value")
+			.build();
+
+		assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(authorizationServerMetadata.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(authorizationServerMetadata.getPushedAuthorizationRequestEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/par"));
+		assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(authorizationServerMetadata.getScopes()).containsExactly("openid");
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code");
+		assertThat(authorizationServerMetadata.getGrantTypes()).containsExactlyInAnyOrder("authorization_code",
+				"client_credentials");
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/revoke"));
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/introspect"));
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("S256");
+		assertThat(authorizationServerMetadata.isTlsClientCertificateBoundAccessTokens()).isTrue();
+		assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).containsExactly(JwsAlgorithms.RS256,
+				JwsAlgorithms.ES256);
+		assertThat(authorizationServerMetadata.getClaimAsString("a-claim")).isEqualTo("a-value");
+	}
+
+	@Test
+	public void buildWhenOnlyRequiredClaimsProvidedThenCreated() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.responseType("code")
+			.build();
+
+		assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(authorizationServerMetadata.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(authorizationServerMetadata.getPushedAuthorizationRequestEndpoint()).isNull();
+		assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getJwkSetUrl()).isNull();
+		assertThat(authorizationServerMetadata.getScopes()).isNull();
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code");
+		assertThat(authorizationServerMetadata.getGrantTypes()).isNull();
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isNull();
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isNull();
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
+		assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).isNull();
+	}
+
+	@Test
+	public void withClaimsWhenClaimsProvidedThenCreated() {
+		HashMap<String, Object> claims = new HashMap<>();
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, "https://example.com");
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT,
+				"https://example.com/oauth2/authorize");
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.PUSHED_AUTHORIZATION_REQUEST_ENDPOINT,
+				"https://example.com/oauth2/par");
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/oauth2/token");
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, "https://example.com/oauth2/jwks");
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED,
+				Collections.singletonList("code"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT,
+				"https://example.com/oauth2/revoke");
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT,
+				"https://example.com/oauth2/introspect");
+		claims.put("some-claim", "some-value");
+
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata
+			.withClaims(claims)
+			.build();
+
+		assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(authorizationServerMetadata.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(authorizationServerMetadata.getPushedAuthorizationRequestEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/par"));
+		assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(authorizationServerMetadata.getScopes()).containsExactly("openid");
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code");
+		assertThat(authorizationServerMetadata.getGrantTypes()).isNull();
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/revoke"));
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/introspect"));
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
+		assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).isNull();
+		assertThat(authorizationServerMetadata.getClaimAsString("some-claim")).isEqualTo("some-value");
+	}
+
+	@Test
+	public void withClaimsWhenClaimsWithUrlsProvidedThenCreated() {
+		HashMap<String, Object> claims = new HashMap<>();
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, url("https://example.com"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT,
+				url("https://example.com/oauth2/authorize"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.PUSHED_AUTHORIZATION_REQUEST_ENDPOINT,
+				url("https://example.com/oauth2/par"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/oauth2/token"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, url("https://example.com/oauth2/jwks"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED,
+				Collections.singletonList("code"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT,
+				url("https://example.com/oauth2/revoke"));
+		claims.put(OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT,
+				url("https://example.com/oauth2/introspect"));
+		claims.put("some-claim", "some-value");
+
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata
+			.withClaims(claims)
+			.build();
+
+		assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(authorizationServerMetadata.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(authorizationServerMetadata.getPushedAuthorizationRequestEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/par"));
+		assertThat(authorizationServerMetadata.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(authorizationServerMetadata.getScopes()).isNull();
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code");
+		assertThat(authorizationServerMetadata.getGrantTypes()).isNull();
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/revoke"));
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/introspect"));
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
+		assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).isNull();
+		assertThat(authorizationServerMetadata.getClaimAsString("some-claim")).isEqualTo("some-value");
+	}
+
+	@Test
+	public void withClaimsWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OAuth2AuthorizationServerMetadata.withClaims(null))
+			.withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void withClaimsWhenMissingRequiredClaimsThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OAuth2AuthorizationServerMetadata.withClaims(Collections.emptyMap()))
+			.withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void buildWhenCalledTwiceThenGeneratesTwoConfigurations() {
+		OAuth2AuthorizationServerMetadata first = this.minimalBuilder.grantType("client_credentials").build();
+
+		OAuth2AuthorizationServerMetadata second = this.minimalBuilder.claims((claims) -> {
+			List<String> newGrantTypes = new ArrayList<>();
+			newGrantTypes.add("authorization_code");
+			newGrantTypes.add("custom_grant");
+			claims.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, newGrantTypes);
+		}).build();
+
+		assertThat(first.getGrantTypes()).containsExactly("client_credentials");
+		assertThat(second.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "custom_grant");
+	}
+
+	@Test
+	public void buildWhenMissingIssuerThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.ISSUER));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("issuer cannot be null");
+	}
+
+	@Test
+	public void buildWhenIssuerNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.ISSUER, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("issuer must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingAuthorizationEndpointThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("authorizationEndpoint cannot be null");
+	}
+
+	@Test
+	public void buildWhenAuthorizationEndpointNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.AUTHORIZATION_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("authorizationEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenPushedAuthorizationRequestEndpointNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.PUSHED_AUTHORIZATION_REQUEST_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("pushedAuthorizationRequestEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingTokenEndpointThenThrowsIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("tokenEndpoint cannot be null");
+	}
+
+	@Test
+	public void buildWhenTokenEndpointNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("tokenEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenTokenEndpointAuthenticationMethodsNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("tokenEndpointAuthenticationMethods must be of type List");
+	}
+
+	@Test
+	public void buildWhenTokenEndpointAuthenticationMethodsEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims.put(
+				OAuth2AuthorizationServerMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHODS_SUPPORTED,
+				Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("tokenEndpointAuthenticationMethods cannot be empty");
+	}
+
+	@Test
+	public void buildWhenTokenEndpointAuthenticationMethodsAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.tokenEndpointAuthenticationMethod("should-be-removed")
+			.tokenEndpointAuthenticationMethods((authMethods) -> {
+				authMethods.clear();
+				authMethods.add("some-authentication-method");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods())
+			.containsExactly("some-authentication-method");
+	}
+
+	@Test
+	public void buildWhenJwksUriNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.JWKS_URI, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("jwksUri must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenScopesNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("scopes must be of type List");
+	}
+
+	@Test
+	public void buildWhenScopesEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED, Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("scopes cannot be empty");
+	}
+
+	@Test
+	public void buildWhenScopesAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder.scope("should-be-removed")
+			.scopes((scopes) -> {
+				scopes.clear();
+				scopes.add("some-scope");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getScopes()).containsExactly("some-scope");
+	}
+
+	@Test
+	public void buildWhenMissingResponseTypesThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("responseTypes cannot be null");
+	}
+
+	@Test
+	public void buildWhenResponseTypesNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("responseTypes must be of type List");
+	}
+
+	@Test
+	public void buildWhenResponseTypesEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("responseTypes cannot be empty");
+	}
+
+	@Test
+	public void buildWhenResponseTypesAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.responseType("should-be-removed")
+			.responseTypes((responseTypes) -> {
+				responseTypes.clear();
+				responseTypes.add("some-response-type");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("some-response-type");
+	}
+
+	@Test
+	public void buildWhenResponseTypesNotPresentAndAddingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.claims((claims) -> claims.remove(OAuth2AuthorizationServerMetadataClaimNames.RESPONSE_TYPES_SUPPORTED))
+			.responseTypes((responseTypes) -> responseTypes.add("some-response-type"))
+			.build();
+
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("some-response-type");
+	}
+
+	@Test
+	public void buildWhenGrantTypesNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("grantTypes must be of type List");
+	}
+
+	@Test
+	public void buildWhenGrantTypesEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.GRANT_TYPES_SUPPORTED, Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("grantTypes cannot be empty");
+	}
+
+	@Test
+	public void buildWhenGrantTypesAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.grantType("should-be-removed")
+			.grantTypes((grantTypes) -> {
+				grantTypes.clear();
+				grantTypes.add("some-grant-type");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getGrantTypes()).containsExactly("some-grant-type");
+	}
+
+	@Test
+	public void buildWhenTokenRevocationEndpointNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.tokenRevocationEndpoint("not a valid URL");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("tokenRevocationEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenTokenRevocationEndpointAuthenticationMethodsNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("tokenRevocationEndpointAuthenticationMethods must be of type List");
+	}
+
+	@Test
+	public void buildWhenTokenRevocationEndpointAuthenticationMethodsEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims.put(
+				OAuth2AuthorizationServerMetadataClaimNames.REVOCATION_ENDPOINT_AUTH_METHODS_SUPPORTED,
+				Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("tokenRevocationEndpointAuthenticationMethods cannot be empty");
+	}
+
+	@Test
+	public void buildWhenTokenRevocationEndpointAuthenticationMethodsAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.tokenRevocationEndpointAuthenticationMethod("should-be-removed")
+			.tokenRevocationEndpointAuthenticationMethods((authMethods) -> {
+				authMethods.clear();
+				authMethods.add("some-authentication-method");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods())
+			.containsExactly("some-authentication-method");
+	}
+
+	@Test
+	public void buildWhenTokenIntrospectionEndpointNotUrlThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.tokenIntrospectionEndpoint("not a valid URL");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("tokenIntrospectionEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenTokenIntrospectionEndpointAuthenticationMethodsNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims.put(
+				OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT_AUTH_METHODS_SUPPORTED,
+				"not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("tokenIntrospectionEndpointAuthenticationMethods must be of type List");
+	}
+
+	@Test
+	public void buildWhenTokenIntrospectionEndpointAuthenticationMethodsEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims.put(
+				OAuth2AuthorizationServerMetadataClaimNames.INTROSPECTION_ENDPOINT_AUTH_METHODS_SUPPORTED,
+				Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("tokenIntrospectionEndpointAuthenticationMethods cannot be empty");
+	}
+
+	@Test
+	public void buildWhenTokenIntrospectionEndpointAuthenticationMethodsAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.tokenIntrospectionEndpointAuthenticationMethod("should-be-removed")
+			.tokenIntrospectionEndpointAuthenticationMethods((authMethods) -> {
+				authMethods.clear();
+				authMethods.add("some-authentication-method");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods())
+			.containsExactly("some-authentication-method");
+	}
+
+	@Test
+	public void buildWhenCodeChallengeMethodsNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("codeChallengeMethods must be of type List");
+	}
+
+	@Test
+	public void buildWhenCodeChallengeMethodsEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder
+			.claims((claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.CODE_CHALLENGE_METHODS_SUPPORTED,
+					Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("codeChallengeMethods cannot be empty");
+	}
+
+	@Test
+	public void buildWhenCodeChallengeMethodsAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.codeChallengeMethod("should-be-removed")
+			.codeChallengeMethods((codeChallengeMethods) -> {
+				codeChallengeMethods.clear();
+				codeChallengeMethods.add("some-authentication-method");
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("some-authentication-method");
+	}
+
+	@Test
+	public void buildWhenDPoPSigningAlgorithmsNotListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims((claims) -> claims
+			.put(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED, "not-a-list"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("dPoPSigningAlgorithms must be of type List");
+	}
+
+	@Test
+	public void buildWhenDPoPSigningAlgorithmsEmptyListThenThrowIllegalArgumentException() {
+		Builder builder = this.minimalBuilder.claims(
+				(claims) -> claims.put(OAuth2AuthorizationServerMetadataClaimNames.DPOP_SIGNING_ALG_VALUES_SUPPORTED,
+						Collections.emptyList()));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("dPoPSigningAlgorithms cannot be empty");
+	}
+
+	@Test
+	public void buildWhenDPoPSigningAlgorithmsAddingOrRemovingThenCorrectValues() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.dPoPSigningAlgorithm(JwsAlgorithms.RS256)
+			.dPoPSigningAlgorithms((algs) -> {
+				algs.clear();
+				algs.add(JwsAlgorithms.ES256);
+			})
+			.build();
+
+		assertThat(authorizationServerMetadata.getDPoPSigningAlgorithms()).containsExactly(JwsAlgorithms.ES256);
+	}
+
+	@Test
+	public void claimWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OAuth2AuthorizationServerMetadata.builder().claim(null, "claim-value"))
+			.withMessage("name cannot be empty");
+	}
+
+	@Test
+	public void claimWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OAuth2AuthorizationServerMetadata.builder().claim("claim-name", null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void claimsWhenRemovingClaimThenNotPresent() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.claim("claim-name", "claim-value")
+			.claims((claims) -> claims.remove("claim-name"))
+			.build();
+		assertThat(authorizationServerMetadata.hasClaim("claim-name")).isFalse();
+	}
+
+	@Test
+	public void claimsWhenAddingClaimThenPresent() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.minimalBuilder
+			.claim("claim-name", "claim-value")
+			.build();
+		assertThat(authorizationServerMetadata.hasClaim("claim-name")).isTrue();
+	}
+
+	private static URL url(String urlString) {
+		try {
+			return new URL(urlString);
+		}
+		catch (Exception ex) {
+			throw new IllegalArgumentException("urlString must be a valid URL and valid URI");
+		}
+	}
+
+}

+ 141 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/OAuth2AuthorizationTests.java

@@ -0,0 +1,141 @@
+/*
+ * Copyright 2020-2022 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;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import org.junit.jupiter.api.Test;
+
+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.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2Authorization}.
+ *
+ * @author Krisztian Toth
+ * @author Joe Grandja
+ */
+public class OAuth2AuthorizationTests {
+
+	private static final String ID = "id";
+
+	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
+
+	private static final String PRINCIPAL_NAME = "principal";
+
+	private static final AuthorizationGrantType AUTHORIZATION_GRANT_TYPE = AuthorizationGrantType.AUTHORIZATION_CODE;
+
+	private static final OAuth2AccessToken ACCESS_TOKEN = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+			"access-token", Instant.now(), Instant.now().plusSeconds(300));
+
+	private static final OAuth2RefreshToken REFRESH_TOKEN = new OAuth2RefreshToken("refresh-token", Instant.now());
+
+	private static final OAuth2AuthorizationCode AUTHORIZATION_CODE = new OAuth2AuthorizationCode("code", Instant.now(),
+			Instant.now().plus(5, ChronoUnit.MINUTES));
+
+	@Test
+	public void withRegisteredClientWhenRegisteredClientNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2Authorization.withRegisteredClient(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClient cannot be null");
+	}
+
+	@Test
+	public void fromWhenAuthorizationNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2Authorization.from(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorization cannot be null");
+	}
+
+	@Test
+	public void fromWhenAuthorizationProvidedThenCopied() {
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.accessToken(ACCESS_TOKEN)
+			.build();
+		OAuth2Authorization authorizationResult = OAuth2Authorization.from(authorization).build();
+
+		assertThat(authorizationResult.getId()).isEqualTo(authorization.getId());
+		assertThat(authorizationResult.getRegisteredClientId()).isEqualTo(authorization.getRegisteredClientId());
+		assertThat(authorizationResult.getPrincipalName()).isEqualTo(authorization.getPrincipalName());
+		assertThat(authorizationResult.getAuthorizationGrantType())
+			.isEqualTo(authorization.getAuthorizationGrantType());
+		assertThat(authorizationResult.getAccessToken()).isEqualTo(authorization.getAccessToken());
+		assertThat(authorizationResult.getToken(OAuth2AuthorizationCode.class))
+			.isEqualTo(authorization.getToken(OAuth2AuthorizationCode.class));
+		assertThat(authorizationResult.getAttributes()).isEqualTo(authorization.getAttributes());
+	}
+
+	@Test
+	public void buildWhenPrincipalNameNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT).build())
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("principalName cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAuthorizationGrantTypeNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT).principalName(PRINCIPAL_NAME).build())
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationGrantType cannot be null");
+	}
+
+	@Test
+	public void attributeWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT).attribute(null, AUTHORIZATION_CODE))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("name cannot be empty");
+	}
+
+	@Test
+	public void attributeWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT).attribute("name", null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("value cannot be null");
+	}
+
+	@Test
+	public void buildWhenAllAttributesAreProvidedThenAllAttributesAreSet() {
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(REGISTERED_CLIENT)
+			.id(ID)
+			.principalName(PRINCIPAL_NAME)
+			.authorizationGrantType(AUTHORIZATION_GRANT_TYPE)
+			.token(AUTHORIZATION_CODE)
+			.accessToken(ACCESS_TOKEN)
+			.refreshToken(REFRESH_TOKEN)
+			.build();
+
+		assertThat(authorization.getId()).isEqualTo(ID);
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(REGISTERED_CLIENT.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(PRINCIPAL_NAME);
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AUTHORIZATION_GRANT_TYPE);
+		assertThat(authorization.getToken(OAuth2AuthorizationCode.class).getToken()).isEqualTo(AUTHORIZATION_CODE);
+		assertThat(authorization.getAccessToken().getToken()).isEqualTo(ACCESS_TOKEN);
+		assertThat(authorization.getRefreshToken().getToken()).isEqualTo(REFRESH_TOKEN);
+	}
+
+}

+ 129 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/TestOAuth2Authorizations.java

@@ -0,0 +1,129 @@
+/*
+ * Copyright 2020-2022 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;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+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.OAuth2AuthorizationRequest;
+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.settings.OAuth2TokenFormat;
+import org.springframework.util.CollectionUtils;
+
+/**
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+public final class TestOAuth2Authorizations {
+
+	private TestOAuth2Authorizations() {
+	}
+
+	public static OAuth2Authorization.Builder authorization() {
+		return authorization(TestRegisteredClients.registeredClient().build());
+	}
+
+	public static OAuth2Authorization.Builder authorization(RegisteredClient registeredClient) {
+		return authorization(registeredClient, Collections.emptyMap());
+	}
+
+	public static OAuth2Authorization.Builder authorization(RegisteredClient registeredClient,
+			Map<String, Object> authorizationRequestAdditionalParameters) {
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode("code", Instant.now(),
+				Instant.now().plusSeconds(120));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				Instant.now(), Instant.now().plusSeconds(300));
+		return authorization(registeredClient, authorizationCode, accessToken, Collections.emptyMap(),
+				authorizationRequestAdditionalParameters);
+	}
+
+	public static OAuth2Authorization.Builder authorization(RegisteredClient registeredClient,
+			OAuth2AuthorizationCode authorizationCode) {
+		return authorization(registeredClient, authorizationCode, null, Collections.emptyMap(), Collections.emptyMap());
+	}
+
+	public static OAuth2Authorization.Builder authorization(RegisteredClient registeredClient,
+			OAuth2AccessToken accessToken, Map<String, Object> accessTokenClaims) {
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode("code", Instant.now(),
+				Instant.now().plusSeconds(120));
+		return authorization(registeredClient, authorizationCode, accessToken, accessTokenClaims,
+				Collections.emptyMap());
+	}
+
+	private static OAuth2Authorization.Builder authorization(RegisteredClient registeredClient,
+			OAuth2AuthorizationCode authorizationCode, OAuth2AccessToken accessToken,
+			Map<String, Object> accessTokenClaims, Map<String, Object> authorizationRequestAdditionalParameters) {
+		OAuth2AuthorizationRequest authorizationRequest = OAuth2AuthorizationRequest.authorizationCode()
+			.authorizationUri("https://provider.com/oauth2/authorize")
+			.clientId(registeredClient.getClientId())
+			.redirectUri(registeredClient.getRedirectUris().iterator().next())
+			.scopes(registeredClient.getScopes())
+			.additionalParameters(authorizationRequestAdditionalParameters)
+			.state("state")
+			.build();
+		OAuth2Authorization.Builder builder = OAuth2Authorization.withRegisteredClient(registeredClient)
+			.id("id")
+			.principalName("principal")
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.authorizedScopes(authorizationRequest.getScopes())
+			.token(authorizationCode)
+			.attribute(OAuth2ParameterNames.STATE, "consent-state")
+			.attribute(OAuth2AuthorizationRequest.class.getName(), authorizationRequest)
+			.attribute(Principal.class.getName(),
+					new TestingAuthenticationToken("principal", null, "ROLE_A", "ROLE_B"));
+		if (accessToken != null) {
+			OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now(),
+					Instant.now().plus(1, ChronoUnit.HOURS));
+			builder
+				.token(accessToken, (metadata) -> metadata.putAll(tokenMetadata(registeredClient, accessTokenClaims)))
+				.refreshToken(refreshToken);
+		}
+
+		return builder;
+	}
+
+	private static Map<String, Object> tokenMetadata(RegisteredClient registeredClient,
+			Map<String, Object> tokenClaims) {
+		Map<String, Object> tokenMetadata = new HashMap<>();
+		OAuth2TokenFormat accessTokenFormat = registeredClient.getTokenSettings().getAccessTokenFormat();
+		tokenMetadata.put(OAuth2TokenFormat.class.getName(), accessTokenFormat.getValue());
+		tokenMetadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, false);
+		if (CollectionUtils.isEmpty(tokenClaims)) {
+			tokenClaims = defaultTokenClaims();
+		}
+		tokenMetadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, tokenClaims);
+		return tokenMetadata;
+	}
+
+	private static Map<String, Object> defaultTokenClaims() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put("claim1", "value1");
+		claims.put("claim2", "value2");
+		claims.put("claim3", "value3");
+		return claims;
+	}
+
+}

+ 113 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/aot/hint/OAuth2AuthorizationServerBeanRegistrationAotProcessorTests.java

@@ -0,0 +1,113 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.aot.hint;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import org.springframework.beans.factory.aot.BeanRegistrationAotContribution;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.beans.factory.support.RegisteredBean;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OAuth2AuthorizationServerBeanRegistrationAotProcessor}.
+ *
+ * @author William Koch
+ */
+class OAuth2AuthorizationServerBeanRegistrationAotProcessorTests {
+
+	private OAuth2AuthorizationServerBeanRegistrationAotProcessor processor;
+
+	private DefaultListableBeanFactory defaultListableBeanFactory;
+
+	@BeforeEach
+	void setUp() {
+		this.processor = new OAuth2AuthorizationServerBeanRegistrationAotProcessor();
+		this.defaultListableBeanFactory = new DefaultListableBeanFactory();
+
+	}
+
+	@ParameterizedTest
+	@ValueSource(classes = { JdbcOAuth2AuthorizationService.class, CustomJdbcOAuth2AuthorizationService.class,
+			JdbcRegisteredClientRepository.class, CustomJdbcRegisteredClientRepository.class })
+	void processAheadOfTimeWhenBeanTypeJdbcBasedImplThenReturnContribution(Class<?> beanClass) {
+		this.defaultListableBeanFactory.registerBeanDefinition("beanName", new RootBeanDefinition(beanClass));
+
+		BeanRegistrationAotContribution aotContribution = this.processor
+			.processAheadOfTime(RegisteredBean.of(this.defaultListableBeanFactory, "beanName"));
+
+		assertThat(aotContribution).isNotNull();
+	}
+
+	@ParameterizedTest
+	@ValueSource(classes = { InMemoryOAuth2AuthorizationService.class, InMemoryRegisteredClientRepository.class,
+			Object.class })
+	void processAheadOfTimeWhenBeanTypeNotJdbcBasedImplThenDoesNotReturnContribution(Class<?> beanClass) {
+		this.defaultListableBeanFactory.registerBeanDefinition("beanName", new RootBeanDefinition(beanClass));
+
+		BeanRegistrationAotContribution aotContribution = this.processor
+			.processAheadOfTime(RegisteredBean.of(this.defaultListableBeanFactory, "beanName"));
+
+		assertThat(aotContribution).isNull();
+	}
+
+	@Test
+	void processAheadOfTimeWhenMultipleBeanTypeJdbcBasedImplThenReturnContributionOnce() {
+		this.defaultListableBeanFactory.registerBeanDefinition("oauth2AuthorizationService",
+				new RootBeanDefinition(JdbcOAuth2AuthorizationService.class));
+
+		this.defaultListableBeanFactory.registerBeanDefinition("registeredClientRepository",
+				new RootBeanDefinition(CustomJdbcRegisteredClientRepository.class));
+
+		BeanRegistrationAotContribution firstAotContribution = this.processor
+			.processAheadOfTime(RegisteredBean.of(this.defaultListableBeanFactory, "oauth2AuthorizationService"));
+
+		BeanRegistrationAotContribution secondAotContribution = this.processor
+			.processAheadOfTime(RegisteredBean.of(this.defaultListableBeanFactory, "registeredClientRepository"));
+
+		assertThat(firstAotContribution).isNotNull();
+		assertThat(secondAotContribution).isNull();
+	}
+
+	static class CustomJdbcOAuth2AuthorizationService extends JdbcOAuth2AuthorizationService {
+
+		CustomJdbcOAuth2AuthorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			super(jdbcOperations, registeredClientRepository);
+		}
+
+	}
+
+	static class CustomJdbcRegisteredClientRepository extends JdbcRegisteredClientRepository {
+
+		CustomJdbcRegisteredClientRepository(JdbcOperations jdbcOperations) {
+			super(jdbcOperations);
+		}
+
+	}
+
+}

+ 388 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/ClientSecretAuthenticationProviderTests.java

@@ -0,0 +1,388 @@
+/*
+ * 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.authentication;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+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 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.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link ClientSecretAuthenticationProvider}.
+ *
+ * @author Patryk Kostrzewa
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+public class ClientSecretAuthenticationProviderTests {
+
+	// See RFC 7636: Appendix B. Example for the S256 code_challenge_method
+	// https://tools.ietf.org/html/rfc7636#appendix-B
+	private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+	private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+	private static final String AUTHORIZATION_CODE = "code";
+
+	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private ClientSecretAuthenticationProvider authenticationProvider;
+
+	private PasswordEncoder passwordEncoder;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new ClientSecretAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService);
+		this.passwordEncoder = spy(new PasswordEncoder() {
+			@Override
+			public String encode(CharSequence rawPassword) {
+				return NoOpPasswordEncoder.getInstance().encode(rawPassword);
+			}
+
+			@Override
+			public boolean matches(CharSequence rawPassword, String encodedPassword) {
+				return NoOpPasswordEncoder.getInstance().matches(rawPassword, encodedPassword);
+			}
+
+			@Override
+			public boolean upgradeEncoding(String encodedPassword) {
+				return true;
+			}
+		});
+		this.authenticationProvider.setPasswordEncoder(this.passwordEncoder);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new ClientSecretAuthenticationProvider(null, this.authorizationService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new ClientSecretAuthenticationProvider(this.registeredClientRepository, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void setPasswordEncoderWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setPasswordEncoder(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("passwordEncoder cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2ClientAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId() + "-invalid", ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
+			});
+	}
+
+	@Test
+	public void authenticateWhenUnsupportedClientAuthenticationMethodThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_POST,
+				registeredClient.getClientSecret(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("authentication_method");
+			});
+	}
+
+	@Test
+	public void authenticateWhenClientSecretNotProvidedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("credentials");
+			});
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientSecretThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret() + "-invalid", null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_SECRET);
+			});
+		verify(this.passwordEncoder).matches(any(), any());
+	}
+
+	@Test
+	public void authenticateWhenExpiredClientSecretThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSecretExpiresAt(Instant.now().minus(1, ChronoUnit.HOURS).truncatedTo(ChronoUnit.SECONDS))
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("client_secret_expires_at");
+			});
+		verify(this.passwordEncoder).matches(any(), any());
+	}
+
+	@Test
+	public void authenticateWhenValidCredentialsThenAuthenticated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.passwordEncoder).matches(any(), any());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret());
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void authenticateWhenValidCredentialsAndRequiresUpgradingThenClientSecretUpgraded() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.passwordEncoder).matches(any(), any());
+		verify(this.passwordEncoder).upgradeEncoding(any());
+		verify(this.passwordEncoder).encode(any());
+		verify(this.registeredClientRepository).save(any());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret());
+		assertThat(authenticationResult.getRegisteredClient()).isNotSameAs(registeredClient);
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeGrantAndValidCredentialsThenAuthenticated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(TestOAuth2Authorizations.authorization().build());
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), createAuthorizationCodeTokenParameters());
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.passwordEncoder).matches(any(), any());
+		verify(this.authorizationService).findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret());
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void authenticateWhenPkceAndInvalidCodeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+		parameters.put(OAuth2ParameterNames.CODE, "invalid-code");
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CODE);
+			});
+	}
+
+	@Test
+	public void authenticateWhenPkceAndMissingCodeVerifierThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+				assertThat(error.getDescription()).contains(PkceParameterNames.CODE_VERIFIER);
+			});
+	}
+
+	@Test
+	public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), parameters);
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.passwordEncoder).matches(any(), any());
+		verify(this.authorizationService).findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret());
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+	}
+
+	private static Map<String, Object> createAuthorizationCodeTokenParameters() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceTokenParameters(String codeVerifier) {
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+		parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceAuthorizationParametersS256() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE);
+		return parameters;
+	}
+
+}

+ 407 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionAuthenticationProviderTests.java

@@ -0,0 +1,407 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.nio.charset.StandardCharsets;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.OctetSequenceKey;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jwt.BadJwtException;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.JwtValidationException;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+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 org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link JwtClientAssertionAuthenticationProvider}.
+ *
+ * @author Rafal Lewczuk
+ * @author Joe Grandja
+ */
+public class JwtClientAssertionAuthenticationProviderTests {
+
+	// See RFC 7636: Appendix B. Example for the S256 code_challenge_method
+	// https://tools.ietf.org/html/rfc7636#appendix-B
+	private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+	private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+	private static final String AUTHORIZATION_CODE = "code";
+
+	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
+
+	private static final ClientAuthenticationMethod JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD = new ClientAuthenticationMethod(
+			"urn:ietf:params:oauth:client-assertion-type:jwt-bearer");
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private JwtClientAssertionAuthenticationProvider authenticationProvider;
+
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new JwtClientAssertionAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService);
+		this.authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://auth-server.com")
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(this.authorizationServerSettings, null));
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new JwtClientAssertionAuthenticationProvider(null, this.authorizationService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new JwtClientAssertionAuthenticationProvider(this.registeredClientRepository, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void setJwtDecoderFactoryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setJwtDecoderFactory(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("jwtDecoderFactory cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2ClientAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId() + "-invalid", JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD,
+				"jwt-assertion", null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
+			});
+	}
+
+	@Test
+	public void authenticateWhenUnsupportedClientAuthenticationMethodThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD, "jwt-assertion", null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("authentication_method");
+			});
+	}
+
+	@Test
+	public void authenticateWhenCredentialsNotProvidedThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("credentials");
+			});
+	}
+
+	@Test
+	public void authenticateWhenInvalidCredentialsThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSettings(
+						ClientSettings.builder()
+								.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD, "invalid-jwt-assertion",
+				null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.hasCauseInstanceOf(BadJwtException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ASSERTION);
+			});
+	}
+
+	@Test
+	public void authenticateWhenInvalidClaimsThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSettings(
+						ClientSettings.builder()
+								.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		// @formatter:off
+		JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256)
+				.build();
+		JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder()
+				.issuer("invalid-iss")
+				.subject("invalid-sub")
+				.audience(Collections.singletonList("invalid-aud"))
+				.build();
+		// @formatter:on
+
+		JwtEncoder jwsEncoder = createEncoder(TestKeys.DEFAULT_ENCODED_SECRET_KEY, "HmacSHA256");
+		Jwt jwtAssertion = jwsEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD,
+				jwtAssertion.getTokenValue(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.hasCauseInstanceOf(JwtValidationException.class)
+			.extracting((ex) -> (OAuth2AuthenticationException) ex)
+			.satisfies((ex) -> {
+				assertThat(ex.getError().getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(ex.getError().getDescription()).contains(OAuth2ParameterNames.CLIENT_ASSERTION);
+				JwtValidationException jwtValidationException = (JwtValidationException) ex.getCause();
+				assertThat(jwtValidationException.getErrors()).hasSize(4); // iss, sub,
+																			// aud, exp
+			});
+	}
+
+	@Test
+	public void authenticateWhenValidCredentialsThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSettings(
+						ClientSettings.builder()
+								.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		// @formatter:off
+		JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256)
+				.build();
+		JwtClaimsSet jwtClaimsSet = jwtClientAssertionClaims(registeredClient)
+				.build();
+		// @formatter:on
+
+		JwtEncoder jwsEncoder = createEncoder(TestKeys.DEFAULT_ENCODED_SECRET_KEY, "HmacSHA256");
+		Jwt jwtAssertion = jwsEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD,
+				jwtAssertion.getTokenValue(), null);
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isInstanceOf(Jwt.class);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT);
+	}
+
+	@Test
+	public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientSecret(TestKeys.DEFAULT_ENCODED_SECRET_KEY)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSettings(
+						ClientSettings.builder()
+								.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		// @formatter:off
+		JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256)
+				.build();
+		JwtClaimsSet jwtClaimsSet = jwtClientAssertionClaims(registeredClient)
+				.build();
+		// @formatter:on
+
+		JwtEncoder jwsEncoder = createEncoder(TestKeys.DEFAULT_ENCODED_SECRET_KEY, "HmacSHA256");
+		Jwt jwtAssertion = jwsEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), JWT_CLIENT_ASSERTION_AUTHENTICATION_METHOD,
+				jwtAssertion.getTokenValue(), parameters);
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isInstanceOf(Jwt.class);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_JWT);
+	}
+
+	private JwtClaimsSet.Builder jwtClientAssertionClaims(RegisteredClient registeredClient) {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		return JwtClaimsSet.builder()
+			.issuer(registeredClient.getClientId())
+			.subject(registeredClient.getClientId())
+			.audience(Collections.singletonList(asUrl(this.authorizationServerSettings.getIssuer(),
+					this.authorizationServerSettings.getTokenEndpoint())))
+			.issuedAt(issuedAt)
+			.expiresAt(expiresAt);
+	}
+
+	private static JwtEncoder createEncoder(String secret, String algorithm) {
+		SecretKey secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), algorithm);
+		OctetSequenceKey secretKeyJwk = TestJwks.jwk(secretKey).build();
+		JWKSource<SecurityContext> jwkSource = (jwkSelector, securityContext) -> jwkSelector
+			.select(new JWKSet(secretKeyJwk));
+		return new NimbusJwtEncoder(jwkSource);
+	}
+
+	private static String asUrl(String uri, String path) {
+		return UriComponentsBuilder.fromUriString(uri).path(path).build().toUriString();
+	}
+
+	private static Map<String, Object> createAuthorizationCodeTokenParameters() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceTokenParameters(String codeVerifier) {
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+		parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceAuthorizationParametersS256() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE);
+		return parameters;
+	}
+
+}

+ 114 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/JwtClientAssertionDecoderFactoryTests.java

@@ -0,0 +1,114 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+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.settings.ClientSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link JwtClientAssertionDecoderFactory}.
+ *
+ * @author Joe Grandja
+ */
+public class JwtClientAssertionDecoderFactoryTests {
+
+	private JwtClientAssertionDecoderFactory jwtDecoderFactory = new JwtClientAssertionDecoderFactory();
+
+	@Test
+	public void setJwtValidatorFactoryWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.jwtDecoderFactory.setJwtValidatorFactory(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("jwtValidatorFactory cannot be null");
+	}
+
+	@Test
+	public void createDecoderWhenMissingJwkSetUrlThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.clientSettings(
+						ClientSettings.builder()
+								.tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+
+		assertThatThrownBy(() -> this.jwtDecoderFactory.createDecoder(registeredClient))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).isEqualTo("Failed to find a Signature Verifier for Client: '"
+						+ registeredClient.getId() + "'. Check to ensure you have configured the JWK Set URL.");
+			});
+	}
+
+	@Test
+	public void createDecoderWhenMissingClientSecretThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientSecret(null)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.clientSettings(
+						ClientSettings.builder()
+								.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+
+		assertThatThrownBy(() -> this.jwtDecoderFactory.createDecoder(registeredClient))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).isEqualTo("Failed to find a Signature Verifier for Client: '"
+						+ registeredClient.getId() + "'. Check to ensure you have configured the client secret.");
+			});
+	}
+
+	@Test
+	public void createDecoderWhenMissingSigningAlgorithmThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT)
+				.build();
+		// @formatter:on
+
+		assertThatThrownBy(() -> this.jwtDecoderFactory.createDecoder(registeredClient))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription())
+					.isEqualTo("Failed to find a Signature Verifier for Client: '" + registeredClient.getId()
+							+ "'. Check to ensure you have configured a valid JWS Algorithm: 'null'.");
+			});
+	}
+
+}

+ 79 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AccessTokenAuthenticationContextTests.java

@@ -0,0 +1,79 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.authentication;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+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.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2AccessTokenAuthenticationContext}
+ *
+ * @author Dmitriy Dubson
+ */
+public class OAuth2AccessTokenAuthenticationContextTests {
+
+	private final RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private final OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(this.registeredClient)
+		.build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private final OAuth2AccessTokenAuthenticationToken accessTokenAuthenticationToken = new OAuth2AccessTokenAuthenticationToken(
+			this.registeredClient, this.clientPrincipal, this.authorization.getAccessToken().getToken(),
+			this.authorization.getRefreshToken().getToken());
+
+	@Test
+	public void withWhenAuthenticationNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2AccessTokenAuthenticationContext.with(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authentication cannot be null");
+	}
+
+	@Test
+	public void setWhenValueNullThenThrowIllegalArgumentException() {
+		OAuth2AccessTokenAuthenticationContext.Builder builder = OAuth2AccessTokenAuthenticationContext
+			.with(this.accessTokenAuthenticationToken);
+
+		assertThatThrownBy(() -> builder.accessTokenResponse(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("value cannot be null");
+	}
+
+	@Test
+	public void buildWhenAllValuesProvidedThenAllValuesAreSet() {
+		OAuth2AccessTokenResponse.Builder accessTokenResponseBuilder = OAuth2AccessTokenResponse
+			.withToken(this.accessTokenAuthenticationToken.getAccessToken().getTokenValue());
+		OAuth2AccessTokenAuthenticationContext context = OAuth2AccessTokenAuthenticationContext
+			.with(this.accessTokenAuthenticationToken)
+			.accessTokenResponse(accessTokenResponseBuilder)
+			.build();
+
+		assertThat(context.<Authentication>getAuthentication()).isEqualTo(this.accessTokenAuthenticationToken);
+		assertThat(context.getAccessTokenResponse()).isEqualTo(accessTokenResponseBuilder);
+	}
+
+}

+ 98 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AccessTokenAuthenticationTokenTests.java

@@ -0,0 +1,98 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2AccessTokenAuthenticationToken}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2AccessTokenAuthenticationTokenTests {
+
+	private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+			Instant.now(), Instant.now().plusSeconds(300));
+
+	private OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now(),
+			Instant.now().plus(1, ChronoUnit.DAYS));
+
+	private Map<String, Object> additionalParameters = Collections.singletonMap("custom-param", "custom-value");
+
+	@Test
+	public void constructorWhenRegisteredClientNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AccessTokenAuthenticationToken(null, this.clientPrincipal, this.accessToken))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClient cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2AccessTokenAuthenticationToken(this.registeredClient, null, this.accessToken))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAccessTokenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2AccessTokenAuthenticationToken(this.registeredClient, this.clientPrincipal, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("accessToken cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAdditionalParametersNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AccessTokenAuthenticationToken(this.registeredClient, this.clientPrincipal,
+				this.accessToken, this.refreshToken, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("additionalParameters cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAllValuesProvidedThenCreated() {
+		OAuth2AccessTokenAuthenticationToken authentication = new OAuth2AccessTokenAuthenticationToken(
+				this.registeredClient, this.clientPrincipal, this.accessToken, this.refreshToken,
+				this.additionalParameters);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getRegisteredClient()).isEqualTo(this.registeredClient);
+		assertThat(authentication.getAccessToken()).isEqualTo(this.accessToken);
+		assertThat(authentication.getRefreshToken()).isEqualTo(this.refreshToken);
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+}

+ 838 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationProviderTests.java

@@ -0,0 +1,838 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.Principal;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+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.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.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+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.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 org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2AuthorizationCodeAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+public class OAuth2AuthorizationCodeAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_CODE = "code";
+
+	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private JwtEncoder jwtEncoder;
+
+	private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
+
+	private OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer;
+
+	private OAuth2TokenGenerator<?> tokenGenerator;
+
+	private JwtEncoder dPoPProofJwtEncoder;
+
+	private SessionRegistry sessionRegistry;
+
+	private OAuth2AuthorizationCodeAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.jwtEncoder = mock(JwtEncoder.class);
+		this.jwtCustomizer = mock(OAuth2TokenCustomizer.class);
+		JwtGenerator jwtGenerator = new JwtGenerator(this.jwtEncoder);
+		jwtGenerator.setJwtCustomizer(this.jwtCustomizer);
+		this.accessTokenCustomizer = mock(OAuth2TokenCustomizer.class);
+		OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
+		accessTokenGenerator.setAccessTokenCustomizer(this.accessTokenCustomizer);
+		OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
+		OAuth2TokenGenerator<OAuth2Token> delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(jwtGenerator,
+				accessTokenGenerator, refreshTokenGenerator);
+		this.tokenGenerator = spy(new OAuth2TokenGenerator<OAuth2Token>() {
+			@Override
+			public OAuth2Token generate(OAuth2TokenContext context) {
+				return delegatingTokenGenerator.generate(context);
+			}
+		});
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		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));
+	}
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeAuthenticationProvider(null, this.tokenGenerator))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeAuthenticationProvider(this.authorizationService, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("tokenGenerator cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2AuthorizationCodeAuthenticationTokenThenReturnTrue() {
+		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();
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(registeredClient.getClientId(),
+				registeredClient.getClientSecret());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenInvalidCodeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenCodeIssuedToAnotherClientThenThrowOAuth2AuthenticationException() {
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization().build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = updatedAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCode.isInvalidated()).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenInvalidRedirectUriThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri() + "-invalid", null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenInvalidatedCodeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(AUTHORIZATION_CODE, Instant.now(),
+				Instant.now().plusSeconds(120));
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(authorizationCode,
+					(metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getAccessToken().isInvalidated()).isTrue();
+		assertThat(updatedAuthorization.getRefreshToken().isInvalidated()).isTrue();
+	}
+
+	// gh-1233
+	@Test
+	public void authenticateWhenInvalidatedCodeAndAccessTokenNullThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(AUTHORIZATION_CODE, Instant.now(),
+				Instant.now().plusSeconds(120));
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient, authorizationCode)
+			.token(authorizationCode,
+					(metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+
+		verify(this.authorizationService, never()).save(any());
+	}
+
+	// gh-290
+	@Test
+	public void authenticateWhenExpiredCodeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode(AUTHORIZATION_CODE,
+				Instant.now().minusSeconds(300), Instant.now().minusSeconds(60));
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(authorizationCode)
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		willAnswer((answer) -> {
+			OAuth2TokenContext context = answer.getArgument(0);
+			if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+				return null;
+			}
+			else {
+				return answer.callRealMethod();
+			}
+		}).given(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription()).contains("The token generator failed to generate the access token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenInvalidRefreshTokenGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt());
+
+		willAnswer((answer) -> {
+			OAuth2TokenContext context = answer.getArgument(0);
+			if (OAuth2TokenType.REFRESH_TOKEN.equals(context.getTokenType())) {
+				return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token", Instant.now(),
+						Instant.now().plusSeconds(300));
+			}
+			else {
+				return answer.callRealMethod();
+			}
+		}).given(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription())
+					.contains("The token generator failed to generate a valid refresh token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenIdTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt());
+
+		willAnswer((answer) -> {
+			OAuth2TokenContext context = answer.getArgument(0);
+			if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
+				return null;
+			}
+			else {
+				return answer.callRealMethod();
+			}
+		}).given(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription()).contains("The token generator failed to generate the ID token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenValidCodeThenReturnAccessToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), additionalParameters);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt());
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer).customize(jwtEncodingContextCaptor.capture());
+		JwtEncodingContext jwtEncodingContext = jwtEncodingContextCaptor.getValue();
+		assertThat(jwtEncodingContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(jwtEncodingContext.<Authentication>getPrincipal())
+			.isEqualTo(authorization.getAttribute(Principal.class.getName()));
+		assertThat(jwtEncodingContext.getAuthorization()).isEqualTo(authorization);
+		assertThat(jwtEncodingContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(jwtEncodingContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(jwtEncodingContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(jwtEncodingContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
+		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
+
+		ArgumentCaptor<JwtEncoderParameters> jwtEncoderParametersCaptor = ArgumentCaptor
+			.forClass(JwtEncoderParameters.class);
+		verify(this.jwtEncoder).encode(jwtEncoderParametersCaptor.capture());
+		JwtClaimsSet jwtClaimsSet = jwtEncoderParametersCaptor.getValue().getClaims();
+
+		Set<String> scopes = jwtClaimsSet.getClaim(OAuth2ParameterNames.SCOPE);
+		assertThat(scopes).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(jwtClaimsSet.getSubject()).isEqualTo(authorization.getPrincipalName());
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getRegisteredClient().getId())
+			.isEqualTo(updatedAuthorization.getRegisteredClientId());
+		assertThat(accessTokenAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(accessTokenAuthentication.getAccessToken())
+			.isEqualTo(updatedAuthorization.getAccessToken().getToken());
+		assertThat(accessTokenAuthentication.getAccessToken().getScopes())
+			.isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(accessTokenAuthentication.getRefreshToken()).isNotNull();
+		assertThat(accessTokenAuthentication.getRefreshToken())
+			.isEqualTo(updatedAuthorization.getRefreshToken().getToken());
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = updatedAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCode.isInvalidated()).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenValidCodeAndAuthenticationRequestThenReturnIdToken() throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode("code", Instant.now(),
+				Instant.now().plusSeconds(120));
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient, authorizationCode)
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(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
+		given(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(false))).willReturn(sessions);
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer, times(2)).customize(jwtEncodingContextCaptor.capture());
+		// Access Token context
+		JwtEncodingContext accessTokenContext = jwtEncodingContextCaptor.getAllValues().get(0);
+		assertThat(accessTokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(accessTokenContext.<Authentication>getPrincipal()).isEqualTo(principal);
+		assertThat(accessTokenContext.getAuthorization()).isEqualTo(authorization);
+		assertThat(accessTokenContext.getAuthorization().getAccessToken()).isNull();
+		assertThat(accessTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(accessTokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(accessTokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(accessTokenContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(accessTokenContext.getJwsHeader()).isNotNull();
+		assertThat(accessTokenContext.getClaims()).isNotNull();
+		Map<String, Object> claims = new HashMap<>();
+		accessTokenContext.getClaims().claims(claims::putAll);
+		assertThat(claims).flatExtracting(OAuth2ParameterNames.SCOPE)
+			.containsExactlyInAnyOrder(OidcScopes.OPENID, "scope1");
+		// ID Token context
+		JwtEncodingContext idTokenContext = jwtEncodingContextCaptor.getAllValues().get(1);
+		assertThat(idTokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		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();
+		assertThat(sessionInformation.getSessionId()).isEqualTo(createHash(expectedSession.getSessionId()));
+		assertThat(idTokenContext.getJwsHeader()).isNotNull();
+		assertThat(idTokenContext.getClaims()).isNotNull();
+
+		verify(this.jwtEncoder, times(2)).encode(any()); // Access token and ID Token
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getRegisteredClient().getId())
+			.isEqualTo(updatedAuthorization.getRegisteredClientId());
+		assertThat(accessTokenAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(accessTokenAuthentication.getAccessToken())
+			.isEqualTo(updatedAuthorization.getAccessToken().getToken());
+		Set<String> accessTokenScopes = new HashSet<>(updatedAuthorization.getAuthorizedScopes());
+		assertThat(accessTokenAuthentication.getAccessToken().getScopes()).isEqualTo(accessTokenScopes);
+		assertThat(accessTokenAuthentication.getRefreshToken()).isNotNull();
+		assertThat(accessTokenAuthentication.getRefreshToken())
+			.isEqualTo(updatedAuthorization.getRefreshToken().getToken());
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCodeToken = updatedAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCodeToken.isInvalidated()).isTrue();
+		OAuth2Authorization.Token<OidcIdToken> idToken = updatedAuthorization.getToken(OidcIdToken.class);
+		assertThat(idToken).isNotNull();
+		assertThat(accessTokenAuthentication.getAdditionalParameters())
+			.containsExactly(entry(OidcParameterNames.ID_TOKEN, idToken.getToken().getTokenValue()));
+	}
+
+	// gh-296
+	@Test
+	public void authenticateWhenPublicClientThenRefreshTokenNotIssued() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.NONE, null);
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt());
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer).customize(jwtEncodingContextCaptor.capture());
+		JwtEncodingContext jwtEncodingContext = jwtEncodingContextCaptor.getValue();
+		assertThat(jwtEncodingContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(jwtEncodingContext.<Authentication>getPrincipal())
+			.isEqualTo(authorization.getAttribute(Principal.class.getName()));
+		assertThat(jwtEncodingContext.getAuthorization()).isEqualTo(authorization);
+		assertThat(jwtEncodingContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(jwtEncodingContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(jwtEncodingContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(jwtEncodingContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
+		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+
+		ArgumentCaptor<JwtEncoderParameters> jwtEncoderParametersCaptor = ArgumentCaptor
+			.forClass(JwtEncoderParameters.class);
+		verify(this.jwtEncoder).encode(jwtEncoderParametersCaptor.capture());
+		JwtClaimsSet jwtClaimsSet = jwtEncoderParametersCaptor.getValue().getClaims();
+
+		Set<String> scopes = jwtClaimsSet.getClaim(OAuth2ParameterNames.SCOPE);
+		assertThat(scopes).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(jwtClaimsSet.getSubject()).isEqualTo(authorization.getPrincipalName());
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getRegisteredClient().getId())
+			.isEqualTo(updatedAuthorization.getRegisteredClientId());
+		assertThat(accessTokenAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(accessTokenAuthentication.getAccessToken())
+			.isEqualTo(updatedAuthorization.getAccessToken().getToken());
+		assertThat(accessTokenAuthentication.getAccessToken().getScopes())
+			.isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(accessTokenAuthentication.getRefreshToken()).isNull();
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = updatedAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCode.isInvalidated()).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenTokenTimeToLiveConfiguredThenTokenExpirySet() {
+		Duration accessTokenTTL = Duration.ofHours(2);
+		Duration refreshTokenTTL = Duration.ofDays(1);
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.tokenSettings(TokenSettings.builder()
+				.accessTokenTimeToLive(accessTokenTTL)
+				.refreshTokenTimeToLive(refreshTokenTTL)
+				.build())
+			.build();
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		Instant accessTokenIssuedAt = Instant.now();
+		Instant accessTokenExpiresAt = accessTokenIssuedAt.plus(accessTokenTTL);
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt(accessTokenIssuedAt, accessTokenExpiresAt));
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getAccessToken())
+			.isEqualTo(updatedAuthorization.getAccessToken().getToken());
+		Instant expectedAccessTokenExpiresAt = accessTokenAuthentication.getAccessToken()
+			.getIssuedAt()
+			.plus(accessTokenTTL);
+		assertThat(accessTokenAuthentication.getAccessToken().getExpiresAt())
+			.isBetween(expectedAccessTokenExpiresAt.minusSeconds(1), expectedAccessTokenExpiresAt.plusSeconds(1));
+
+		assertThat(accessTokenAuthentication.getRefreshToken())
+			.isEqualTo(updatedAuthorization.getRefreshToken().getToken());
+		Instant expectedRefreshTokenExpiresAt = accessTokenAuthentication.getRefreshToken()
+			.getIssuedAt()
+			.plus(refreshTokenTTL);
+		assertThat(accessTokenAuthentication.getRefreshToken().getExpiresAt())
+			.isBetween(expectedRefreshTokenExpiresAt.minusSeconds(1), expectedRefreshTokenExpiresAt.plusSeconds(1));
+	}
+
+	@Test
+	public void authenticateWhenRefreshTokenGrantNotConfiguredThenRefreshTokenNotIssued() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantTypes((grantTypes) -> grantTypes.remove(AuthorizationGrantType.REFRESH_TOKEN))
+			.build();
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt());
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(accessTokenAuthentication.getRefreshToken()).isNull();
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenFormatReferenceThenAccessTokenGeneratorCalled() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(TokenSettings.builder()
+						.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
+						.build())
+				.build();
+		// @formatter:on
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				AUTHORIZATION_CODE, clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		this.authenticationProvider.authenticate(authentication);
+
+		verify(this.accessTokenCustomizer).customize(any());
+	}
+
+	private static Jwt createJwt() {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		return createJwt(issuedAt, expiresAt);
+	}
+
+	private static Jwt createJwt(Instant issuedAt, Instant expiresAt) {
+		return Jwt.withTokenValue("token")
+			.header(JoseHeaderNames.ALG, SignatureAlgorithm.RS256.getName())
+			.issuedAt(issuedAt)
+			.expiresAt(expiresAt)
+			.build();
+	}
+
+	private static String createHash(String value) throws NoSuchAlgorithmException {
+		MessageDigest md = MessageDigest.getInstance("SHA-256");
+		byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII));
+		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+	}
+
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+}

+ 90 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeAuthenticationTokenTests.java

@@ -0,0 +1,90 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2AuthorizationCodeAuthenticationToken}.
+ *
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+public class OAuth2AuthorizationCodeAuthenticationTokenTests {
+
+	private String code = "code";
+
+	private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private String redirectUri = "redirectUri";
+
+	private Map<String, Object> additionalParameters = Collections.singletonMap("param1", "value1");
+
+	@Test
+	public void constructorWhenCodeNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeAuthenticationToken(null, this.clientPrincipal,
+				this.redirectUri, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("code cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2AuthorizationCodeAuthenticationToken(this.code, null, this.redirectUri, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalProvidedThenCreated() {
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				this.code, this.clientPrincipal, this.redirectUri, this.additionalParameters);
+		assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getCode()).isEqualTo(this.code);
+		assertThat(authentication.getRedirectUri()).isEqualTo(this.redirectUri);
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+	@Test
+	public void getAdditionalParametersWhenUpdateThenThrowUnsupportedOperationException() {
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				this.code, this.clientPrincipal, this.redirectUri, this.additionalParameters);
+		assertThatThrownBy(() -> authentication.getAdditionalParameters().put("another_key", 1))
+			.isInstanceOf(UnsupportedOperationException.class);
+		assertThatThrownBy(() -> authentication.getAdditionalParameters().remove("some_key"))
+			.isInstanceOf(UnsupportedOperationException.class);
+		assertThatThrownBy(() -> authentication.getAdditionalParameters().clear())
+			.isInstanceOf(UnsupportedOperationException.class);
+	}
+
+}

+ 817 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationProviderTests.java

@@ -0,0 +1,817 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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 org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+import org.springframework.util.StringUtils;
+
+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.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2AuthorizationCodeRequestAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ * @author Steve Riesenberg
+ */
+public class OAuth2AuthorizationCodeRequestAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/authorize";
+
+	private static final String STATE = "state";
+
+	private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+
+	private OAuth2AuthorizationCodeRequestAuthenticationProvider authenticationProvider;
+
+	private TestingAuthenticationToken principal;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class);
+		this.authenticationProvider = new OAuth2AuthorizationCodeRequestAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.authorizationConsentService);
+		this.principal = new TestingAuthenticationToken("principalName", "password");
+		this.principal.setAuthenticated(true);
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://provider.com")
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationProvider(null,
+				this.authorizationService, this.authorizationConsentService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2AuthorizationCodeRequestAuthenticationProvider(this.registeredClientRepository, null,
+						this.authorizationConsentService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationConsentServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2AuthorizationCodeRequestAuthenticationProvider(this.registeredClientRepository,
+						this.authorizationService, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationConsentService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2AuthorizationCodeRequestAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeRequestAuthenticationToken.class))
+			.isTrue();
+	}
+
+	@Test
+	public void setAuthorizationCodeGeneratorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthorizationCodeGenerator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationCodeGenerator cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authenticationValidator cannot be null");
+	}
+
+	@Test
+	public void setAuthorizationConsentRequiredWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthorizationConsentRequired(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationConsentRequired cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null));
+	}
+
+	// gh-243
+	@Test
+	public void authenticateWhenInvalidRedirectUriHostThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, "https:///invalid", STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	// gh-243
+	@Test
+	public void authenticateWhenInvalidRedirectUriFragmentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, "https://example.com#fragment",
+				STATE, registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenUnregisteredRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, "https://invalid-example.com", STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	// gh-243
+	@Test
+	public void authenticateWhenRedirectUriIPv4LoopbackAndDifferentPortThenReturnAuthorizationCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.redirectUri("https://127.0.0.1:8080")
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, "https://127.0.0.1:5000", STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+	}
+
+	// gh-243
+	@Test
+	public void authenticateWhenRedirectUriIPv6LoopbackAndDifferentPortThenReturnAuthorizationCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.redirectUri("https://[::1]:8080")
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, "https://[::1]:5000", STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenMissingRedirectUriAndMultipleRegisteredThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.redirectUri("https://example2.com")
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestMissingRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		// redirect_uri is REQUIRED for OpenID Connect requests
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthorizedToRequestCodeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantTypes(Set::clear)
+			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				Collections.singleton("invalid-scope"), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPkceRequiredAndMissingCodeChallengeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(ClientSettings.builder().requireProofKey(true).build())
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPkceUnsupportedCodeChallengeMethodThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge");
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported");
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD,
+					authentication.getRedirectUri()));
+	}
+
+	// gh-770
+	@Test
+	public void authenticateWhenPkceMissingCodeChallengeMethodThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge");
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestWithPromptNoneLoginThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		assertWhenAuthenticationRequestWithPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+				"none login");
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestWithPromptNoneConsentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		assertWhenAuthenticationRequestWithPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+				"none consent");
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestWithPromptNoneSelectAccountThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		assertWhenAuthenticationRequestWithPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+				"none select_account");
+	}
+
+	private void assertWhenAuthenticationRequestWithPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+			String prompt) {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("prompt", prompt);
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, "prompt", authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedAndPromptNoneThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		this.principal.setAuthenticated(false);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("prompt", "none");
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					"login_required", "prompt", authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenReturnAuthorizationCodeRequest() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		this.principal.setAuthenticated(false);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(authenticationResult).isSameAs(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenRequireAuthorizationConsentAndPromptNoneThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.scope(OidcScopes.OPENID)
+			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("prompt", "none");
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					"consent_required", "prompt", authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenRequireAuthorizationConsentThenReturnAuthorizationConsent() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationConsentAuthenticationToken authenticationResult = (OAuth2AuthorizationConsentAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE);
+		assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo(authentication.getAuthorizationUri());
+		assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authorizationRequest.getRedirectUri()).isEqualTo(authentication.getRedirectUri());
+		assertThat(authorizationRequest.getScopes()).isEqualTo(authentication.getScopes());
+		assertThat(authorizationRequest.getState()).isEqualTo(authentication.getState());
+		assertThat(authorizationRequest.getAdditionalParameters()).isEqualTo(authentication.getAdditionalParameters());
+
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(this.principal.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authorization.<Authentication>getAttribute(Principal.class.getName())).isEqualTo(this.principal);
+		String state = authorization.getAttribute(OAuth2ParameterNames.STATE);
+		assertThat(state).isNotNull();
+		assertThat(state).isNotEqualTo(authentication.getState());
+
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri());
+		assertThat(authenticationResult.getScopes()).isEmpty();
+		assertThat(authenticationResult.getState()).isEqualTo(state);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenRequireAuthorizationConsentAndOnlyOpenidScopeRequestedThenAuthorizationConsentNotRequired() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
+			.scopes((scopes) -> {
+				scopes.clear();
+				scopes.add(OidcScopes.OPENID);
+			})
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenRequireAuthorizationConsentAndAllPreviouslyApprovedThenAuthorizationConsentNotRequired() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build())
+			.build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2AuthorizationConsent.Builder builder = OAuth2AuthorizationConsent.withId(registeredClient.getId(),
+				this.principal.getName());
+		registeredClient.getScopes().forEach(builder::scope);
+		OAuth2AuthorizationConsent previousAuthorizationConsent = builder.build();
+		given(this.authorizationConsentService.findById(eq(registeredClient.getId()), eq(this.principal.getName())))
+			.willReturn(previousAuthorizationConsent);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthorizationConsentRequiredThenUsed() {
+		@SuppressWarnings("unchecked")
+		Predicate<OAuth2AuthorizationCodeRequestAuthenticationContext> authorizationConsentRequired = mock(
+				Predicate.class);
+		this.authenticationProvider.setAuthorizationConsentRequired(authorizationConsentRequired);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+
+		verify(authorizationConsentRequired).test(any());
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeRequestValidThenReturnAuthorizationCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge");
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeRequestWithRequestUriThenReturnAuthorizationCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri
+			.create();
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(OAuth2ParameterNames.REQUEST_URI, pushedAuthorizationRequestUri.getRequestUri());
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, additionalParameters)
+			.build();
+		given(this.authorizationService.findByToken(eq(pushedAuthorizationRequestUri.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, null, null,
+				additionalParameters);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+		verify(this.authorizationService).remove(eq(authorization));
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeRequestWithInvalidRequestUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+		OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri
+			.create();
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(OAuth2ParameterNames.REQUEST_URI, pushedAuthorizationRequestUri.getRequestUri());
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, additionalParameters)
+			.build();
+		given(this.authorizationService.findByToken(eq(pushedAuthorizationRequestUri.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, null, null,
+				Collections.singletonMap(OAuth2ParameterNames.REQUEST_URI, "invalid_request_uri"));
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REQUEST_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeRequestWithRequestUriIssuedToAnotherClientThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient anotherRegisteredClient = TestRegisteredClients.registeredClient2().build();
+
+		OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri
+			.create();
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(OAuth2ParameterNames.REQUEST_URI, pushedAuthorizationRequestUri.getRequestUri());
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, additionalParameters)
+			.build();
+		given(this.authorizationService.findByToken(eq(pushedAuthorizationRequestUri.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, anotherRegisteredClient.getClientId(), this.principal, null, null, null,
+				additionalParameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null));
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeRequestWithExpiredRequestUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+		OAuth2PushedAuthorizationRequestUri pushedAuthorizationRequestUri = OAuth2PushedAuthorizationRequestUri
+			.create(Instant.now().minusSeconds(5));
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(OAuth2ParameterNames.REQUEST_URI, pushedAuthorizationRequestUri.getRequestUri());
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, additionalParameters)
+			.build();
+		given(this.authorizationService.findByToken(eq(pushedAuthorizationRequestUri.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, null, null, null,
+				additionalParameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REQUEST_URI, null));
+		verify(this.authorizationService).remove(eq(authorization));
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationCodeNotGeneratedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2AuthorizationCode> authorizationCodeGenerator = mock(OAuth2TokenGenerator.class);
+		this.authenticationProvider.setAuthorizationCodeGenerator(authorizationCodeGenerator);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthorizationCodeRequestAuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription())
+					.contains("The token generator failed to generate the authorization code.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthenticationValidatorThenUsed() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		@SuppressWarnings("unchecked")
+		Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authenticationValidator = mock(Consumer.class);
+		this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
+
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationCodeRequestWithAuthorizationCodeResult(registeredClient, authentication,
+				authenticationResult);
+
+		verify(authenticationValidator).accept(any());
+	}
+
+	private void assertAuthorizationCodeRequestWithAuthorizationCodeResult(RegisteredClient registeredClient,
+			OAuth2AuthorizationCodeRequestAuthenticationToken authentication,
+			OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult) {
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE);
+		assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo(authentication.getAuthorizationUri());
+		assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId());
+
+		String requestUri = (String) authentication.getAdditionalParameters().get(OAuth2ParameterNames.REQUEST_URI);
+		if (!StringUtils.hasText(requestUri)) {
+			assertThat(authorizationRequest.getRedirectUri()).isEqualTo(authentication.getRedirectUri());
+			assertThat(authorizationRequest.getScopes()).isEqualTo(authentication.getScopes());
+			assertThat(authorizationRequest.getState()).isEqualTo(authentication.getState());
+		}
+
+		assertThat(authorizationRequest.getAdditionalParameters()).isEqualTo(authentication.getAdditionalParameters());
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(this.principal.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authorization.<Authentication>getAttribute(Principal.class.getName())).isEqualTo(this.principal);
+
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = authorization
+			.getToken(OAuth2AuthorizationCode.class);
+		Set<String> authorizedScopes = authorization.getAuthorizedScopes();
+
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri());
+		assertThat(authenticationResult.getRedirectUri()).isEqualTo(authorizationRequest.getRedirectUri());
+		assertThat(authenticationResult.getScopes()).isEqualTo(authorizedScopes);
+		assertThat(authenticationResult.getState()).isEqualTo(authorizationRequest.getState());
+		assertThat(authenticationResult.getAuthorizationCode()).isEqualTo(authorizationCode.getToken());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+	}
+
+	private static void assertAuthenticationException(
+			OAuth2AuthorizationCodeRequestAuthenticationException authenticationException, String errorCode,
+			String parameterName, String redirectUri) {
+
+		OAuth2Error error = authenticationException.getError();
+		assertThat(error.getErrorCode()).isEqualTo(errorCode);
+		assertThat(error.getDescription()).contains(parameterName);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationException
+			.getAuthorizationCodeRequestAuthentication();
+		assertThat(authorizationCodeRequestAuthentication.getRedirectUri()).isEqualTo(redirectUri);
+	}
+
+}

+ 130 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationCodeRequestAuthenticationTokenTests.java

@@ -0,0 +1,130 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2AuthorizationCodeRequestAuthenticationToken}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2AuthorizationCodeRequestAuthenticationTokenTests {
+
+	private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/authorize";
+
+	private static final RegisteredClient REGISTERED_CLIENT = TestRegisteredClients.registeredClient().build();
+
+	private static final TestingAuthenticationToken PRINCIPAL = new TestingAuthenticationToken("principalName",
+			"password");
+
+	private static final OAuth2AuthorizationCode AUTHORIZATION_CODE = new OAuth2AuthorizationCode("code", Instant.now(),
+			Instant.now().plus(5, ChronoUnit.MINUTES));
+
+	@Test
+	public void constructorWhenAuthorizationUriNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationToken(null,
+				REGISTERED_CLIENT.getClientId(), PRINCIPAL, null, null, (Set<String>) null, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationUri cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientIdNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationToken(AUTHORIZATION_URI, null,
+				PRINCIPAL, null, null, (Set<String>) null, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientId cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenPrincipalNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationToken(AUTHORIZATION_URI,
+				REGISTERED_CLIENT.getClientId(), null, null, null, (Set<String>) null, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("principal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationCodeNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationCodeRequestAuthenticationToken(AUTHORIZATION_URI,
+				REGISTERED_CLIENT.getClientId(), PRINCIPAL, null, null, null, (Set<String>) null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationCode cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationRequestThenValuesAreSet() {
+		String clientId = REGISTERED_CLIENT.getClientId();
+		String redirectUri = REGISTERED_CLIENT.getRedirectUris().iterator().next();
+		String state = "state";
+		Set<String> requestedScopes = REGISTERED_CLIENT.getScopes();
+		Map<String, Object> additionalParameters = Collections.singletonMap("param1", "value1");
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, clientId, PRINCIPAL, redirectUri, state, requestedScopes, additionalParameters);
+
+		assertThat(authentication.getPrincipal()).isEqualTo(PRINCIPAL);
+		assertThat(authentication.getCredentials()).isEqualTo("");
+		assertThat(authentication.getAuthorities()).isEmpty();
+		assertThat(authentication.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI);
+		assertThat(authentication.getClientId()).isEqualTo(clientId);
+		assertThat(authentication.getRedirectUri()).isEqualTo(redirectUri);
+		assertThat(authentication.getState()).isEqualTo(state);
+		assertThat(authentication.getScopes()).containsExactlyInAnyOrderElementsOf(requestedScopes);
+		assertThat(authentication.getAdditionalParameters()).containsExactlyInAnyOrderEntriesOf(additionalParameters);
+		assertThat(authentication.getAuthorizationCode()).isNull();
+		assertThat(authentication.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationResponseThenValuesAreSet() {
+		String clientId = REGISTERED_CLIENT.getClientId();
+		String redirectUri = REGISTERED_CLIENT.getRedirectUris().iterator().next();
+		String state = "state";
+		Set<String> authorizedScopes = REGISTERED_CLIENT.getScopes();
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authentication = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				AUTHORIZATION_URI, clientId, PRINCIPAL, AUTHORIZATION_CODE, redirectUri, state, authorizedScopes);
+
+		assertThat(authentication.getPrincipal()).isEqualTo(PRINCIPAL);
+		assertThat(authentication.getCredentials()).isEqualTo("");
+		assertThat(authentication.getAuthorities()).isEmpty();
+		assertThat(authentication.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI);
+		assertThat(authentication.getClientId()).isEqualTo(clientId);
+		assertThat(authentication.getRedirectUri()).isEqualTo(redirectUri);
+		assertThat(authentication.getState()).isEqualTo(state);
+		assertThat(authentication.getScopes()).containsExactlyInAnyOrderElementsOf(authorizedScopes);
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+		assertThat(authentication.getAuthorizationCode()).isEqualTo(AUTHORIZATION_CODE);
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+}

+ 122 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationContextTests.java

@@ -0,0 +1,122 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.security.Principal;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+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.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2AuthorizationConsentAuthenticationContext}.
+ *
+ * @author Steve Riesenberg
+ * @author Joe Grandja
+ */
+public class OAuth2AuthorizationConsentAuthenticationContextTests {
+
+	private final RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private final OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(this.registeredClient)
+		.build();
+
+	private final Authentication principal = this.authorization.getAttribute(Principal.class.getName());
+
+	private final OAuth2AuthorizationRequest authorizationRequest = this.authorization
+		.getAttribute(OAuth2AuthorizationRequest.class.getName());
+
+	private final OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthentication = new OAuth2AuthorizationConsentAuthenticationToken(
+			this.authorizationRequest.getAuthorizationUri(), this.registeredClient.getClientId(), this.principal,
+			"state", null, null);
+
+	private final OAuth2AuthorizationConsent.Builder authorizationConsentBuilder = OAuth2AuthorizationConsent
+		.withId(this.authorization.getRegisteredClientId(), this.authorization.getPrincipalName());
+
+	@Test
+	public void withWhenAuthenticationNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2AuthorizationConsentAuthenticationContext.with(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authentication cannot be null");
+	}
+
+	@Test
+	public void setWhenValueNullThenThrowIllegalArgumentException() {
+		OAuth2AuthorizationConsentAuthenticationContext.Builder builder = OAuth2AuthorizationConsentAuthenticationContext
+			.with(this.authorizationConsentAuthentication);
+
+		assertThatThrownBy(() -> builder.authorizationConsent(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.registeredClient(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.authorization(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.authorizationRequest(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.put(null, "")).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenRequiredValueNullThenThrowIllegalArgumentException() {
+		OAuth2AuthorizationConsentAuthenticationContext.Builder builder = OAuth2AuthorizationConsentAuthenticationContext
+			.with(this.authorizationConsentAuthentication);
+
+		assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationConsentBuilder cannot be null");
+		builder.authorizationConsent(this.authorizationConsentBuilder);
+
+		assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClient cannot be null");
+		builder.registeredClient(this.registeredClient);
+
+		assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorization cannot be null");
+		builder.authorization(this.authorization);
+
+		assertThatThrownBy(builder::build).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationRequest cannot be null");
+		builder.authorizationRequest(this.authorizationRequest);
+
+		builder.build();
+	}
+
+	@Test
+	public void buildWhenAllValuesProvidedThenAllValuesAreSet() {
+		OAuth2AuthorizationConsentAuthenticationContext context = OAuth2AuthorizationConsentAuthenticationContext
+			.with(this.authorizationConsentAuthentication)
+			.authorizationConsent(this.authorizationConsentBuilder)
+			.registeredClient(this.registeredClient)
+			.authorization(this.authorization)
+			.authorizationRequest(this.authorizationRequest)
+			.put("custom-key-1", "custom-value-1")
+			.context((ctx) -> ctx.put("custom-key-2", "custom-value-2"))
+			.build();
+
+		assertThat(context.<Authentication>getAuthentication()).isEqualTo(this.authorizationConsentAuthentication);
+		assertThat(context.getAuthorizationConsent()).isEqualTo(this.authorizationConsentBuilder);
+		assertThat(context.getRegisteredClient()).isEqualTo(this.registeredClient);
+		assertThat(context.getAuthorization()).isEqualTo(this.authorization);
+		assertThat(context.getAuthorizationRequest()).isEqualTo(this.authorizationRequest);
+		assertThat(context.<String>get("custom-key-1")).isEqualTo("custom-value-1");
+		assertThat(context.<String>get("custom-key-2")).isEqualTo("custom-value-2");
+	}
+
+}

+ 536 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2AuthorizationConsentAuthenticationProviderTests.java

@@ -0,0 +1,536 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.security.Principal;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2AuthorizationConsentAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ * @author Steve Riesenberg
+ */
+public class OAuth2AuthorizationConsentAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/authorize";
+
+	private static final String STATE = "state";
+
+	private static final OAuth2TokenType STATE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.STATE);
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+
+	private OAuth2AuthorizationConsentAuthenticationProvider authenticationProvider;
+
+	private TestingAuthenticationToken principal;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class);
+		this.authenticationProvider = new OAuth2AuthorizationConsentAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.authorizationConsentService);
+		this.principal = new TestingAuthenticationToken("principalName", "password");
+		this.principal.setAuthenticated(true);
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://provider.com")
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationConsentAuthenticationProvider(null, this.authorizationService,
+				this.authorizationConsentService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationConsentAuthenticationProvider(this.registeredClientRepository,
+				null, this.authorizationConsentService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationConsentServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2AuthorizationConsentAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationConsentService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2AuthorizationConsentAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2AuthorizationConsentAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void setAuthorizationCodeGeneratorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthorizationCodeGenerator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationCodeGenerator cannot be null");
+	}
+
+	@Test
+	public void setAuthorizationConsentCustomizerWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthorizationConsentCustomizer(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationConsentCustomizer cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenInvalidStateThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, registeredClient.getScopes(),
+				null);
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, null));
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, registeredClient.getScopes(),
+				null);
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+		this.principal.setAuthenticated(false);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, null));
+	}
+
+	@Test
+	public void authenticateWhenInvalidPrincipalThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName().concat("-other"))
+			.build();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, registeredClient.getScopes(),
+				null);
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.STATE, null));
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		given(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))).willReturn(authorization);
+		RegisteredClient otherRegisteredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, otherRegisteredClient.getClientId(), this.principal, STATE,
+				registeredClient.getScopes(), null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null));
+	}
+
+	@Test
+	public void authenticateWhenDoesNotMatchClientThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		RegisteredClient otherRegisteredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(otherRegisteredClient)
+			.principalName(this.principal.getName())
+			.build();
+		given(this.authorizationService.findByToken(eq("state"), eq(STATE_TOKEN_TYPE))).willReturn(authorization);
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, registeredClient.getScopes(),
+				null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.CLIENT_ID, null));
+	}
+
+	@Test
+	public void authenticateWhenScopeNotRequestedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Set<String> authorizedScopes = new HashSet<>(authorizationRequest.getScopes());
+		authorizedScopes.add("scope-not-requested");
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, authorizedScopes, null);
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, authorizationRequest.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenNotApprovedThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, new HashSet<>(), null); // No
+																													// scopes
+																													// approved
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID,
+					authorizationRequest.getRedirectUri()));
+
+		verify(this.authorizationService).remove(eq(authorization));
+	}
+
+	@Test
+	public void authenticateWhenApproveAllThenReturnAuthorizationCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Set<String> authorizedScopes = authorizationRequest.getScopes();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, authorizedScopes, null); // Approve
+																													// all
+																													// scopes
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationConsentRequestWithAuthorizationCodeResult(registeredClient, authorization,
+				authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthorizationConsentCustomizerThenUsed() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Set<String> authorizedScopes = authorizationRequest.getScopes();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, authorizedScopes, null); // Approve
+																													// all
+																													// scopes
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		@SuppressWarnings("unchecked")
+		Consumer<OAuth2AuthorizationConsentAuthenticationContext> authorizationConsentCustomizer = mock(Consumer.class);
+		this.authenticationProvider.setAuthorizationConsentCustomizer(authorizationConsentCustomizer);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertAuthorizationConsentRequestWithAuthorizationCodeResult(registeredClient, authorization,
+				authenticationResult);
+
+		ArgumentCaptor<OAuth2AuthorizationConsentAuthenticationContext> authenticationContextCaptor = ArgumentCaptor
+			.forClass(OAuth2AuthorizationConsentAuthenticationContext.class);
+		verify(authorizationConsentCustomizer).accept(authenticationContextCaptor.capture());
+
+		OAuth2AuthorizationConsentAuthenticationContext authenticationContext = authenticationContextCaptor.getValue();
+		assertThat(authenticationContext.<Authentication>getAuthentication()).isEqualTo(authentication);
+		assertThat(authenticationContext.getAuthorizationConsent()).isNotNull();
+		assertThat(authenticationContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationContext.getAuthorization()).isEqualTo(authorization);
+		assertThat(authenticationContext.getAuthorizationRequest()).isEqualTo(authorizationRequest);
+	}
+
+	private void assertAuthorizationConsentRequestWithAuthorizationCodeResult(RegisteredClient registeredClient,
+			OAuth2Authorization authorization, OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult) {
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Set<String> authorizedScopes = authorizationRequest.getScopes();
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> authorizationConsentCaptor = ArgumentCaptor
+			.forClass(OAuth2AuthorizationConsent.class);
+		verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture());
+		OAuth2AuthorizationConsent authorizationConsent = authorizationConsentCaptor.getValue();
+
+		assertThat(authorizationConsent.getRegisteredClientId()).isEqualTo(authorization.getRegisteredClientId());
+		assertThat(authorizationConsent.getPrincipalName()).isEqualTo(authorization.getPrincipalName());
+		assertThat(authorizationConsent.getAuthorities()).hasSize(authorizedScopes.size());
+		assertThat(authorizationConsent.getScopes()).containsExactlyInAnyOrderElementsOf(authorizedScopes);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(updatedAuthorization.getRegisteredClientId()).isEqualTo(authorization.getRegisteredClientId());
+		assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authorization.getPrincipalName());
+		assertThat(updatedAuthorization.getAuthorizationGrantType())
+			.isEqualTo(authorization.getAuthorizationGrantType());
+		assertThat(updatedAuthorization.<Authentication>getAttribute(Principal.class.getName()))
+			.isEqualTo(authorization.<Authentication>getAttribute(Principal.class.getName()));
+		assertThat(updatedAuthorization
+			.<OAuth2AuthorizationRequest>getAttribute(OAuth2AuthorizationRequest.class.getName()))
+			.isEqualTo(authorizationRequest);
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCode = updatedAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCode).isNotNull();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		assertThat(updatedAuthorization.getAuthorizedScopes()).isEqualTo(authorizedScopes);
+
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri());
+		assertThat(authenticationResult.getRedirectUri()).isEqualTo(authorizationRequest.getRedirectUri());
+		assertThat(authenticationResult.getScopes()).isEqualTo(authorizedScopes);
+		assertThat(authenticationResult.getState()).isEqualTo(authorizationRequest.getState());
+		assertThat(authenticationResult.getAuthorizationCode()).isEqualTo(authorizationCode.getToken());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenApproveNoneAndRevokePreviouslyApprovedThenAuthorizationConsentRemoved() {
+		String previouslyApprovedScope = "message.read";
+		String requestedScope = "message.write";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
+			scopes.clear();
+			scopes.add(previouslyApprovedScope);
+			scopes.add(requestedScope);
+		}).build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, new HashSet<>(), null); // No
+																													// scopes
+																													// approved
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+		OAuth2AuthorizationConsent previousAuthorizationConsent = OAuth2AuthorizationConsent
+			.withId(authorization.getRegisteredClientId(), authorization.getPrincipalName())
+			.scope(previouslyApprovedScope)
+			.build();
+		given(this.authorizationConsentService.findById(eq(authorization.getRegisteredClientId()),
+				eq(authorization.getPrincipalName())))
+			.willReturn(previousAuthorizationConsent);
+
+		// Revoke all (including previously approved)
+		this.authenticationProvider.setAuthorizationConsentCustomizer(
+				(authorizationConsentContext) -> authorizationConsentContext.getAuthorizationConsent()
+					.authorities(Set::clear));
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.ACCESS_DENIED, OAuth2ParameterNames.CLIENT_ID,
+					authorizationRequest.getRedirectUri()));
+
+		verify(this.authorizationConsentService).remove(eq(previousAuthorizationConsent));
+		verify(this.authorizationService).remove(eq(authorization));
+	}
+
+	@Test
+	public void authenticateWhenApproveSomeAndPreviouslyApprovedThenAuthorizationConsentUpdated() {
+		String previouslyApprovedScope = "message.read";
+		String requestedScope = "message.write";
+		String otherPreviouslyApprovedScope = "other.scope";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
+			scopes.clear();
+			scopes.add(previouslyApprovedScope);
+			scopes.add(requestedScope);
+		}).build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		Set<String> requestedScopes = authorizationRequest.getScopes();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, requestedScopes, null);
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+		OAuth2AuthorizationConsent previousAuthorizationConsent = OAuth2AuthorizationConsent
+			.withId(authorization.getRegisteredClientId(), authorization.getPrincipalName())
+			.scope(previouslyApprovedScope)
+			.scope(otherPreviouslyApprovedScope)
+			.build();
+		given(this.authorizationConsentService.findById(eq(authorization.getRegisteredClientId()),
+				eq(authorization.getPrincipalName())))
+			.willReturn(previousAuthorizationConsent);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> authorizationConsentCaptor = ArgumentCaptor
+			.forClass(OAuth2AuthorizationConsent.class);
+		verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture());
+		OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue();
+
+		assertThat(updatedAuthorizationConsent.getRegisteredClientId())
+			.isEqualTo(previousAuthorizationConsent.getRegisteredClientId());
+		assertThat(updatedAuthorizationConsent.getPrincipalName())
+			.isEqualTo(previousAuthorizationConsent.getPrincipalName());
+		assertThat(updatedAuthorizationConsent.getScopes()).containsExactlyInAnyOrder(previouslyApprovedScope,
+				otherPreviouslyApprovedScope, requestedScope);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getAuthorizedScopes()).isEqualTo(requestedScopes);
+		assertThat(authenticationResult.getScopes()).isEqualTo(requestedScopes);
+	}
+
+	@Test
+	public void authenticateWhenApproveNoneAndPreviouslyApprovedThenAuthorizationConsentNotUpdated() {
+		String previouslyApprovedScope = "message.read";
+		String requestedScope = "message.write";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
+			scopes.clear();
+			scopes.add(previouslyApprovedScope);
+			scopes.add(requestedScope);
+		}).build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(this.principal.getName())
+			.build();
+		OAuth2AuthorizationConsentAuthenticationToken authentication = new OAuth2AuthorizationConsentAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), this.principal, STATE, new HashSet<>(), null); // No
+																													// scopes
+																													// approved
+		given(this.authorizationService.findByToken(eq(authentication.getState()), eq(STATE_TOKEN_TYPE)))
+			.willReturn(authorization);
+		OAuth2AuthorizationConsent previousAuthorizationConsent = OAuth2AuthorizationConsent
+			.withId(authorization.getRegisteredClientId(), authorization.getPrincipalName())
+			.scope(previouslyApprovedScope)
+			.build();
+		given(this.authorizationConsentService.findById(eq(authorization.getRegisteredClientId()),
+				eq(authorization.getPrincipalName())))
+			.willReturn(previousAuthorizationConsent);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authenticationResult = (OAuth2AuthorizationCodeRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationConsentService, never()).save(any());
+		assertThat(authenticationResult.getScopes()).isEqualTo(Collections.singleton(previouslyApprovedScope));
+	}
+
+	private static void assertAuthenticationException(
+			OAuth2AuthorizationCodeRequestAuthenticationException authenticationException, String errorCode,
+			String parameterName, String redirectUri) {
+
+		OAuth2Error error = authenticationException.getError();
+		assertThat(error.getErrorCode()).isEqualTo(errorCode);
+		assertThat(error.getDescription()).contains(parameterName);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationException
+			.getAuthorizationCodeRequestAuthentication();
+		assertThat(authorizationCodeRequestAuthentication.getRedirectUri()).isEqualTo(redirectUri);
+	}
+
+}

+ 83 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientAuthenticationTokenTests.java

@@ -0,0 +1,83 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2ClientAuthenticationToken}.
+ *
+ * @author Joe Grandja
+ * @author Anoop Garlapati
+ */
+public class OAuth2ClientAuthenticationTokenTests {
+
+	@Test
+	public void constructorWhenClientIdNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2ClientAuthenticationToken(null,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "secret", null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientId cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientAuthenticationMethodNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2ClientAuthenticationToken("clientId", null, "clientSecret", null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientAuthenticationMethod cannot be null");
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2ClientAuthenticationToken(null,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "clientSecret"))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClient cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientCredentialsProvidedThenCreated() {
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken("clientId",
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, "secret", null);
+		assertThat(authentication.isAuthenticated()).isFalse();
+		assertThat(authentication.getPrincipal().toString()).isEqualTo("clientId");
+		assertThat(authentication.getCredentials()).isEqualTo("secret");
+		assertThat(authentication.getRegisteredClient()).isNull();
+		assertThat(authentication.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientProvidedThenCreated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		assertThat(authentication.isAuthenticated()).isTrue();
+		assertThat(authentication.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authentication.getCredentials().toString()).isEqualTo(registeredClient.getClientSecret());
+		assertThat(authentication.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authentication.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
+	}
+
+}

+ 396 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationProviderTests.java

@@ -0,0 +1,396 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+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.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+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.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 org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+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.BDDMockito.given;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2ClientCredentialsAuthenticationProvider}.
+ *
+ * @author Alexey Nesterov
+ * @author Joe Grandja
+ */
+public class OAuth2ClientCredentialsAuthenticationProviderTests {
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private JwtEncoder jwtEncoder;
+
+	private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
+
+	private OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer;
+
+	private OAuth2TokenGenerator<?> tokenGenerator;
+
+	private JwtEncoder dPoPProofJwtEncoder;
+
+	private OAuth2ClientCredentialsAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.jwtEncoder = mock(JwtEncoder.class);
+		this.jwtCustomizer = mock(OAuth2TokenCustomizer.class);
+		JwtGenerator jwtGenerator = new JwtGenerator(this.jwtEncoder);
+		jwtGenerator.setJwtCustomizer(this.jwtCustomizer);
+		this.accessTokenCustomizer = mock(OAuth2TokenCustomizer.class);
+		OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
+		accessTokenGenerator.setAccessTokenCustomizer(this.accessTokenCustomizer);
+		OAuth2TokenGenerator<OAuth2Token> delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(jwtGenerator,
+				accessTokenGenerator);
+		this.tokenGenerator = spy(new OAuth2TokenGenerator<OAuth2Token>() {
+			@Override
+			public OAuth2Token generate(OAuth2TokenContext context) {
+				return delegatingTokenGenerator.generate(context);
+			}
+		});
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		this.authenticationProvider = new OAuth2ClientCredentialsAuthenticationProvider(this.authorizationService,
+				this.tokenGenerator);
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://provider.com")
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
+	}
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2ClientCredentialsAuthenticationProvider(null, this.tokenGenerator))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2ClientCredentialsAuthenticationProvider(this.authorizationService, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("tokenGenerator cannot be null");
+	}
+
+	@Test
+	public void supportsWhenSupportedAuthenticationThenTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientCredentialsAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void supportsWhenUnsupportedAuthenticationThenFalse() {
+		assertThat(this.authenticationProvider.supports(OAuth2AuthorizationCodeAuthenticationToken.class)).isFalse();
+	}
+
+	@Test
+	public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authenticationValidator cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(registeredClient.getClientId(),
+				registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthorizedToRequestTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2()
+			.authorizationGrantTypes((grantTypes) -> grantTypes.remove(AuthorizationGrantType.CLIENT_CREDENTIALS))
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, Collections.singleton("invalid-scope"), null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+	}
+
+	@Test
+	public void authenticateWhenScopeRequestedThenAccessTokenContainsScope() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Set<String> requestedScope = Collections.singleton("scope1");
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, requestedScope, null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt(Collections.singleton("mapped-scoped")));
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(accessTokenAuthentication.getAccessToken().getScopes()).isEqualTo(requestedScope);
+	}
+
+	@Test
+	public void authenticateWhenNoScopeRequestedThenAccessTokenDoesNotContainScope() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt(Collections.singleton("mapped-scoped")));
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(accessTokenAuthentication.getAccessToken().getScopes()).isEmpty();
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+
+		doReturn(null).when(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription()).contains("The token generator failed to generate the access token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenValidAuthenticationThenReturnAccessToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, additionalParameters);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt(registeredClient.getScopes()));
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer).customize(jwtEncodingContextCaptor.capture());
+		JwtEncodingContext jwtEncodingContext = jwtEncodingContextCaptor.getValue();
+		assertThat(jwtEncodingContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(jwtEncodingContext.<Authentication>getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(jwtEncodingContext.getAuthorization()).isNull();
+		assertThat(jwtEncodingContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(jwtEncodingContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS);
+		assertThat(jwtEncodingContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
+		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(jwtEncodingContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(clientPrincipal.getRegisteredClient().getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(clientPrincipal.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS);
+		assertThat(authorization.getAccessToken()).isNotNull();
+		assertThat(authorization.getAuthorizedScopes()).isNotNull();
+		assertThat(authorization.getAccessToken().getToken().getScopes())
+			.isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(accessTokenAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(accessTokenAuthentication.getAccessToken()).isEqualTo(authorization.getAccessToken().getToken());
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenFormatReferenceThenAccessTokenGeneratorCalled() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2()
+				.tokenSettings(TokenSettings.builder()
+						.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
+						.build())
+				.build();
+		// @formatter:on
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+
+		this.authenticationProvider.authenticate(authentication);
+
+		verify(this.accessTokenCustomizer).customize(any());
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthenticationValidatorThenUsed() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, registeredClient.getScopes(), null);
+
+		@SuppressWarnings("unchecked")
+		Consumer<OAuth2ClientCredentialsAuthenticationContext> authenticationValidator = mock(Consumer.class);
+		this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
+
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt(registeredClient.getScopes()));
+
+		this.authenticationProvider.authenticate(authentication);
+
+		verify(authenticationValidator).accept(any(OAuth2ClientCredentialsAuthenticationContext.class));
+	}
+
+	private static Jwt createJwt(Set<String> scope) {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		return Jwt.withTokenValue("token")
+			.header(JoseHeaderNames.ALG, SignatureAlgorithm.RS256.getName())
+			.issuedAt(issuedAt)
+			.expiresAt(expiresAt)
+			.claim(OAuth2ParameterNames.SCOPE, scope)
+			.build();
+	}
+
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+}

+ 83 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2ClientCredentialsAuthenticationTokenTests.java

@@ -0,0 +1,83 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2ClientCredentialsAuthenticationToken}.
+ *
+ * @author Alexey Nesterov
+ */
+public class OAuth2ClientCredentialsAuthenticationTokenTests {
+
+	private final RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private final OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+			this.registeredClient, ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+			this.registeredClient.getClientSecret());
+
+	private Set<String> scopes = Collections.singleton("scope1");
+
+	private Map<String, Object> additionalParameters = Collections.singletonMap("param1", "value1");
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2ClientCredentialsAuthenticationToken(null, this.scopes, this.additionalParameters))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalProvidedThenCreated() {
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				this.clientPrincipal, this.scopes, this.additionalParameters);
+
+		assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getScopes()).isEqualTo(this.scopes);
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+	@Test
+	public void constructorWhenScopesProvidedThenCreated() {
+		Set<String> expectedScopes = Collections.singleton("test-scope");
+
+		OAuth2ClientCredentialsAuthenticationToken authentication = new OAuth2ClientCredentialsAuthenticationToken(
+				this.clientPrincipal, expectedScopes, this.additionalParameters);
+
+		assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.CLIENT_CREDENTIALS);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getScopes()).isEqualTo(expectedScopes);
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+}

+ 468 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationConsentAuthenticationProviderTests.java

@@ -0,0 +1,468 @@
+/*
+ * 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.authentication;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationConsentAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationConsentAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private static final String STATE = "abc123";
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+
+	private OAuth2DeviceAuthorizationConsentAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class);
+		this.authenticationProvider = new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.authorizationConsentService);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+						null, this.authorizationService, this.authorizationConsentService))
+				.withMessage("registeredClientRepository cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+						this.registeredClientRepository, null, this.authorizationConsentService))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationConsentAuthenticationProvider(
+						this.registeredClientRepository, this.authorizationService, null))
+				.withMessage("authorizationConsentService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setAuthorizationConsentCustomizerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authenticationProvider.setAuthorizationConsentCustomizer(null))
+				.withMessageContaining("authorizationConsentCustomizer cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceAuthorizationConsentAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationConsentAuthenticationToken.class))
+			.isTrue();
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalIsNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		TestingAuthenticationToken principal = new TestingAuthenticationToken(authorization.getPrincipalName(), null);
+		Authentication authentication = new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI,
+				registeredClient.getClientId(), principal, USER_CODE, STATE, null, Collections.emptyMap());
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNameDoesNotMatchThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("invalid", null, Collections.emptyList());
+		Authentication authentication = new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI,
+				registeredClient.getClientId(), principal, USER_CODE, STATE, null, Collections.emptyMap());
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.STATE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService);
+		verifyNoInteractions(this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientDoesNotMatchAuthorizationThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient2);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(anyString())).willReturn(registeredClient);
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService);
+		verifyNoInteractions(this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenRequestedScopesNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient()
+			.scopes(Set::clear)
+			.scope("invalid")
+			.build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(anyString())).willReturn(registeredClient);
+		Authentication authentication = createAuthentication(registeredClient2);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.SCOPE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+		// @formatter:on
+
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService);
+		verifyNoInteractions(this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenAuthoritiesIsEmptyThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient().scopes(Set::clear).build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient2);
+		Authentication authentication = createAuthentication(registeredClient2);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(anyString())).willReturn(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.ACCESS_DENIED);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAuthoritiesIsNotEmptyThenAuthorizationConsentSaved() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(anyString())).willReturn(registeredClient);
+
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult = (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationConsentService).save(any(OAuth2AuthorizationConsent.class));
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorization.getAuthorizedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		assertThat(updatedAuthorization.<Set<String>>getAttribute(OAuth2ParameterNames.SCOPE)).isNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(false);
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenExistingAuthorizationConsentThenUpdated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope("additional").build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient()
+			.scopes(Set::clear)
+			.scope("additional")
+			.build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient2);
+		Authentication authentication = createAuthentication(registeredClient2);
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName())
+						.scope("scope1").build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(anyString())).willReturn(registeredClient);
+		given(this.authorizationConsentService.findById(anyString(), anyString())).willReturn(authorizationConsent);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult = (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> authorizationConsentCaptor = ArgumentCaptor
+			.forClass(OAuth2AuthorizationConsent.class);
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue();
+		assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorizationConsent.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentCustomizerSetThenUsed() {
+		SimpleGrantedAuthority customAuthority = new SimpleGrantedAuthority("test");
+		this.authenticationProvider.setAuthorizationConsentCustomizer(
+				(context) -> context.getAuthorizationConsent().authority(customAuthority));
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes(Set::clear).build();
+		OAuth2Authorization authorization = createAuthorization(registeredClient);
+		Authentication authentication = createAuthentication(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(anyString())).willReturn(registeredClient);
+		given(this.authorizationConsentService.findById(anyString(), anyString())).willReturn(null);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult = (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isSameAs(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2AuthorizationConsent> authorizationConsentCaptor = ArgumentCaptor
+			.forClass(OAuth2AuthorizationConsent.class);
+		verify(this.authorizationService).findByToken(STATE,
+				OAuth2DeviceAuthorizationConsentAuthenticationProvider.STATE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findByClientId(registeredClient.getClientId());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verify(this.authorizationConsentService).save(authorizationConsentCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2AuthorizationConsent updatedAuthorizationConsent = authorizationConsentCaptor.getValue();
+		assertThat(updatedAuthorizationConsent.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(updatedAuthorizationConsent.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorizationConsent.getAuthorities()).containsExactly(customAuthority);
+	}
+
+	private static OAuth2Authorization createAuthorization(RegisteredClient registeredClient) {
+		// @formatter:off
+		return TestOAuth2Authorizations.authorization(registeredClient)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attributes(Map::clear)
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+	}
+
+	private static OAuth2DeviceAuthorizationConsentAuthenticationToken createAuthentication(
+			RegisteredClient registeredClient) {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", null,
+				Collections.emptyList());
+		Set<String> authorizedScopes = registeredClient.getScopes();
+		if (authorizedScopes.isEmpty()) {
+			authorizedScopes = null;
+		}
+		Map<String, Object> additionalParameters = null;
+		return new OAuth2DeviceAuthorizationConsentAuthenticationToken(AUTHORIZATION_URI,
+				registeredClient.getClientId(), principal, USER_CODE, STATE, authorizedScopes, additionalParameters);
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+
+}

+ 370 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceAuthorizationRequestAuthenticationProviderTests.java

@@ -0,0 +1,370 @@
+/*
+ * 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.authentication;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Set;
+
+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.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+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.AuthorizationServerContextHolder;
+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.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link OAuth2DeviceAuthorizationRequestAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceAuthorizationRequestAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_URI = "/oauth2/device_authorization";
+
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2DeviceAuthorizationRequestAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OAuth2DeviceAuthorizationRequestAuthenticationProvider(
+				this.authorizationService);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceAuthorizationRequestAuthenticationProvider(null))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setDeviceCodeGeneratorWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authenticationProvider.setDeviceCodeGenerator(null))
+				.withMessage("deviceCodeGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setUserCodeGeneratorWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.authenticationProvider.setUserCodeGenerator(null))
+				.withMessage("userCodeGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceAuthorizationRequestAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceAuthorizationRequestAuthenticationToken.class))
+			.isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken("client-1",
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authentication = new OAuth2DeviceAuthorizationRequestAuthenticationToken(
+				clientPrincipal, AUTHORIZATION_URI, null, null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenInvalidGrantTypeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.CLIENT_ID)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopesThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Authentication authentication = new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal,
+				AUTHORIZATION_URI, Collections.singleton("invalid"), null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining(OAuth2ParameterNames.SCOPE)
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeIsNullThenThrowOAuth2AuthenticationException() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2DeviceCode> deviceCodeGenerator = mock(OAuth2TokenGenerator.class);
+		given(deviceCodeGenerator.generate(any(OAuth2TokenContext.class))).willReturn(null);
+		this.authenticationProvider.setDeviceCodeGenerator(deviceCodeGenerator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining("The token generator failed to generate the device code.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(deviceCodeGenerator).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(deviceCodeGenerator);
+		verifyNoInteractions(this.authorizationService);
+	}
+
+	@Test
+	public void authenticateWhenUserCodeIsNullThenThrowOAuth2AuthenticationException() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2UserCode> userCodeGenerator = mock(OAuth2TokenGenerator.class);
+		given(userCodeGenerator.generate(any(OAuth2TokenContext.class))).willReturn(null);
+		this.authenticationProvider.setUserCodeGenerator(userCodeGenerator);
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessageContaining("The token generator failed to generate the user code.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(userCodeGenerator).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(userCodeGenerator);
+		verifyNoInteractions(this.authorizationService);
+	}
+
+	@Test
+	public void authenticateWhenScopesRequestedThenReturnDeviceCodeAndUserCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128);
+		// 8 chars + 1 dash
+		assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull();
+		assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull();
+		assertThat(authorization.<Set<String>>getAttribute(OAuth2ParameterNames.SCOPE))
+			.hasSameElementsAs(registeredClient.getScopes());
+	}
+
+	@Test
+	public void authenticateWhenNoScopesRequestedThenReturnDeviceCodeAndUserCode() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.scopes(Set::clear)
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128);
+		// 8 chars + 1 dash
+		assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull();
+		assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull();
+		assertThat(authorization.<Set<String>>getAttribute(OAuth2ParameterNames.SCOPE))
+			.hasSameElementsAs(registeredClient.getScopes());
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeGeneratorSetThenUsed() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2DeviceCode> deviceCodeGenerator = mock(OAuth2TokenGenerator.class);
+		given(deviceCodeGenerator.generate(any(OAuth2TokenContext.class))).willReturn(createDeviceCode());
+		this.authenticationProvider.setDeviceCodeGenerator(deviceCodeGenerator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).isEqualTo(DEVICE_CODE);
+		// 8 chars + 1 dash
+		assertThat(authenticationResult.getUserCode().getTokenValue()).hasSize(9);
+
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(deviceCodeGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.authorizationService, deviceCodeGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.<Authentication>getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getTokenType())
+			.isEqualTo(OAuth2DeviceAuthorizationRequestAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+	}
+
+	@Test
+	public void authenticateWhenUserCodeGeneratorSetThenUsed() {
+		@SuppressWarnings("unchecked")
+		OAuth2TokenGenerator<OAuth2UserCode> userCodeGenerator = mock(OAuth2TokenGenerator.class);
+		given(userCodeGenerator.generate(any(OAuth2TokenContext.class))).willReturn(createUserCode());
+		this.authenticationProvider.setUserCodeGenerator(userCodeGenerator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+			.build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2DeviceAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getDeviceCode().getTokenValue()).hasSize(128);
+		assertThat(authenticationResult.getUserCode().getTokenValue()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(userCodeGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(any(OAuth2Authorization.class));
+		verifyNoMoreInteractions(this.authorizationService, userCodeGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.<Authentication>getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getTokenType())
+			.isEqualTo(OAuth2DeviceAuthorizationRequestAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2DeviceAuthorizationRequestAuthenticationToken createAuthentication(
+			RegisteredClient registeredClient) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Set<String> requestedScopes = registeredClient.getScopes();
+		if (requestedScopes.isEmpty()) {
+			requestedScopes = null;
+		}
+		return new OAuth2DeviceAuthorizationRequestAuthenticationToken(clientPrincipal, AUTHORIZATION_URI,
+				requestedScopes, null);
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+}

+ 499 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceCodeAuthenticationProviderTests.java

@@ -0,0 +1,499 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+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.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+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.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 org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link OAuth2DeviceCodeAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceCodeAuthenticationProviderTests {
+
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private static final String ACCESS_TOKEN = "abc123";
+
+	private static final String REFRESH_TOKEN = "xyz456";
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;
+
+	private JwtEncoder dPoPProofJwtEncoder;
+
+	private OAuth2DeviceCodeAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	@SuppressWarnings("unchecked")
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.tokenGenerator = mock(OAuth2TokenGenerator.class);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		this.authenticationProvider = new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService,
+				this.tokenGenerator);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceCodeAuthenticationProvider(null, this.tokenGenerator))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceCodeAuthenticationProvider(this.authorizationService, null))
+				.withMessage("tokenGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceCodeAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceCodeAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken("client-1",
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		Authentication authentication = new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE, clientPrincipal, null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientDoesNotMatchClientIdThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient2)
+			.token(createDeviceCode())
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenUserCodeIsNotInvalidatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createDeviceCode())
+			.token(createUserCode())
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2DeviceCodeAuthenticationProvider.AUTHORIZATION_PENDING);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeAndUserCodeAreInvalidatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createDeviceCode(), withInvalidated())
+			.token(createUserCode(), withInvalidated())
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.ACCESS_DENIED);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenDeviceCodeIsExpiredThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createExpiredDeviceCode())
+			.token(createUserCode())
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2DeviceCodeAuthenticationProvider.EXPIRED_TOKEN);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenIsNullThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessage("The token generator failed to generate the access token.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verify(this.tokenGenerator).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenRefreshTokenIsNullThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(createAccessToken(),
+				(OAuth2RefreshToken) null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessage("The token generator failed to generate the refresh token.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verify(this.tokenGenerator, times(2)).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenTokenGeneratorReturnsWrongTypeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Authentication authentication = createAuthentication(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		OAuth2AccessToken accessToken = createAccessToken();
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken, accessToken);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.withMessage("The token generator failed to generate the refresh token.")
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verify(this.tokenGenerator, times(2)).generate(any(OAuth2TokenContext.class));
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenValidDeviceCodeThenReturnAccessTokenAndRefreshToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2DeviceCodeAuthenticationToken authentication = new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE,
+				clientPrincipal, additionalParameters);
+
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(Principal.class.getName(), authentication.getPrincipal())
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		OAuth2AccessToken accessToken = createAccessToken();
+		OAuth2RefreshToken refreshToken = createRefreshToken();
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken, refreshToken);
+		OAuth2AccessTokenAuthenticationToken authenticationResult = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken);
+		assertThat(authenticationResult.getRefreshToken()).isEqualTo(refreshToken);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.authorizationService).findByToken(DEVICE_CODE,
+				OAuth2DeviceCodeAuthenticationProvider.DEVICE_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.tokenGenerator, times(2)).generate(tokenContextCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+		assertThat(updatedAuthorization.getAccessToken().getToken()).isEqualTo(accessToken);
+		assertThat(updatedAuthorization.getRefreshToken().getToken()).isEqualTo(refreshToken);
+
+		for (OAuth2TokenContext tokenContext : tokenContextCaptor.getAllValues()) {
+			assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+			assertThat(tokenContext.<Authentication>getPrincipal()).isEqualTo(authentication.getPrincipal());
+			assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+			assertThat(tokenContext.getAuthorization()).isEqualTo(authorization);
+			assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+			assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.DEVICE_CODE);
+			assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+			assertThat(tokenContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
+		}
+		assertThat(tokenContextCaptor.getAllValues().get(0).getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContextCaptor.getAllValues().get(1).getTokenType()).isEqualTo(OAuth2TokenType.REFRESH_TOKEN);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2DeviceCodeAuthenticationToken createAuthentication(RegisteredClient registeredClient) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		return new OAuth2DeviceCodeAuthenticationToken(DEVICE_CODE, clientPrincipal, null);
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2DeviceCode createExpiredDeviceCode() {
+		Instant issuedAt = Instant.now().minus(45, ChronoUnit.MINUTES);
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2AccessToken createAccessToken() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, ACCESS_TOKEN, issuedAt,
+				issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2RefreshToken createRefreshToken() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2RefreshToken(REFRESH_TOKEN, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static Consumer<Map<String, Object>> withInvalidated() {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+	}
+
+	public static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+}

+ 420 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2DeviceVerificationAuthenticationProviderTests.java

@@ -0,0 +1,420 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link OAuth2DeviceVerificationAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2DeviceVerificationAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_URI = "/oauth2/device_verification";
+
+	private static final String DEVICE_CODE = "EfYu_0jEL";
+
+	private static final String USER_CODE = "BCDF-GHJK";
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+
+	private OAuth2DeviceVerificationAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationConsentService = mock(OAuth2AuthorizationConsentService.class);
+		this.authenticationProvider = new OAuth2DeviceVerificationAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService, this.authorizationConsentService);
+		mockAuthorizationServerContext();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider(
+						null, this.authorizationService, this.authorizationConsentService))
+				.withMessage("registeredClientRepository cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider(
+						this.registeredClientRepository, null, this.authorizationConsentService))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenAuthorizationConsentServiceIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2DeviceVerificationAuthenticationProvider(
+						this.registeredClientRepository, this.authorizationService, null))
+				.withMessage("authorizationConsentService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2DeviceVerificationAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2DeviceVerificationAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationNotFoundThenThrowOAuth2AuthenticationException() {
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(null);
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenUserCodeIsInvalidatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+				.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode(), withInvalidated())
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(eq(USER_CODE),
+				eq(OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenUserCodeIsExpiredAndNotInvalidatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+				.authorization(registeredClient)
+				// Device code would also be expired but not relevant for this test
+				.token(createDeviceCode())
+				.token(createExpiredUserCode())
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(eq(USER_CODE),
+				eq(OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class)).extracting(isInvalidated()).isEqualTo(true);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenReturnUnauthenticated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+				.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null);
+		Authentication authentication = new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE,
+				Collections.emptyMap());
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult = (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult).isEqualTo(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isFalse();
+
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.registeredClientRepository, this.authorizationConsentService);
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentDoesNotExistThenReturnAuthorizationConsentWithState() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		Authentication authentication = createAuthentication();
+		given(this.registeredClientRepository.findById(anyString())).willReturn(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.authorizationConsentService.findById(anyString(), anyString())).willReturn(null);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI);
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authenticationResult.getState()).hasSize(44);
+		assertThat(authenticationResult.getRequestedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getScopes()).isEmpty();
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE))
+			.isEqualTo(authenticationResult.getState());
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesMatchThenReturnDeviceVerification() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attributes(Map::clear)
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName())
+						.scope(registeredClient.getScopes().iterator().next())
+						.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findById(anyString())).willReturn(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.authorizationConsentService.findById(anyString(), anyString())).willReturn(authorizationConsent);
+
+		OAuth2DeviceVerificationAuthenticationToken authenticationResult = (OAuth2DeviceVerificationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(updatedAuthorization.getAuthorizedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(updatedAuthorization.<Authentication>getAttribute(Principal.class.getName()))
+			.isEqualTo(authentication.getPrincipal());
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(false);
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenAuthorizationConsentExistsAndRequestedScopesDoNotMatchThenReturnAuthorizationConsentWithState() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(createDeviceCode())
+				.token(createUserCode())
+				.attributes(Map::clear)
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		Authentication authentication = createAuthentication();
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getId(), authentication.getName())
+						.scope("previous")
+						.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findById(anyString())).willReturn(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		given(this.authorizationConsentService.findById(anyString(), anyString())).willReturn(authorizationConsent);
+
+		OAuth2DeviceAuthorizationConsentAuthenticationToken authenticationResult = (OAuth2DeviceAuthorizationConsentAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(AUTHORIZATION_URI);
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getUserCode()).isEqualTo(USER_CODE);
+		assertThat(authenticationResult.getState()).hasSize(44);
+		assertThat(authenticationResult.getRequestedScopes()).hasSameElementsAs(registeredClient.getScopes());
+		assertThat(authenticationResult.getScopes()).containsExactly("previous");
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).findByToken(USER_CODE,
+				OAuth2DeviceVerificationAuthenticationProvider.USER_CODE_TOKEN_TYPE);
+		verify(this.registeredClientRepository).findById(authorization.getRegisteredClientId());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verify(this.authorizationConsentService).findById(registeredClient.getId(), authentication.getName());
+		verifyNoMoreInteractions(this.registeredClientRepository, this.authorizationService,
+				this.authorizationConsentService);
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		assertThat(updatedAuthorization.<String>getAttribute(OAuth2ParameterNames.STATE))
+			.isEqualTo(authenticationResult.getState());
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2DeviceVerificationAuthenticationToken createAuthentication() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("user", null,
+				AuthorityUtils.createAuthorityList("USER"));
+		return new OAuth2DeviceVerificationAuthenticationToken(principal, USER_CODE, Collections.emptyMap());
+	}
+
+	private static OAuth2DeviceCode createDeviceCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2DeviceCode(DEVICE_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createUserCode() {
+		Instant issuedAt = Instant.now();
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static OAuth2UserCode createExpiredUserCode() {
+		Instant issuedAt = Instant.now().minus(45, ChronoUnit.MINUTES);
+		return new OAuth2UserCode(USER_CODE, issuedAt, issuedAt.plus(30, ChronoUnit.MINUTES));
+	}
+
+	private static Consumer<Map<String, Object>> withInvalidated() {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+
+}

+ 422 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2PushedAuthorizationRequestAuthenticationProviderTests.java

@@ -0,0 +1,422 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+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.settings.ClientSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2PushedAuthorizationRequestAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2PushedAuthorizationRequestAuthenticationProviderTests {
+
+	private static final String AUTHORIZATION_URI = "https://provider.com/oauth2/par";
+
+	private static final String STATE = "state";
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2PushedAuthorizationRequestAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OAuth2PushedAuthorizationRequestAuthenticationProvider(
+				this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2PushedAuthorizationRequestAuthenticationProvider(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2PushedAuthorizationRequestAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2PushedAuthorizationRequestAuthenticationToken.class))
+			.isTrue();
+	}
+
+	@Test
+	public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authenticationValidator cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthorizedToRequestCodeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantTypes(Set::clear)
+			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+			.build();
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[1];
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, null,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.UNAUTHORIZED_CLIENT, OAuth2ParameterNames.CLIENT_ID,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenInvalidRedirectUriHostThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https:///invalid", STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenInvalidRedirectUriFragmentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://example.com#fragment",
+				STATE, registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenUnregisteredRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://invalid-example.com",
+				STATE, registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenRedirectUriIPv4LoopbackAndDifferentPortThenReturnPushedAuthorizationResponse() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.redirectUri("https://127.0.0.1:8080")
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://127.0.0.1:5000", STATE,
+				registeredClient.getScopes(), null);
+		OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenRedirectUriIPv6LoopbackAndDifferentPortThenReturnPushedAuthorizationResponse() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.redirectUri("https://[::1]:8080")
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, "https://[::1]:5000", STATE,
+				registeredClient.getScopes(), null);
+		OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenMissingRedirectUriAndMultipleRegisteredThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.redirectUri("https://example2.com")
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, null, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestMissingRedirectUriThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		// redirect_uri is REQUIRED for OpenID Connect requests
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, null, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, OAuth2ParameterNames.REDIRECT_URI, null));
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				Collections.singleton("invalid-scope"), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_SCOPE, OAuth2ParameterNames.SCOPE, authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPkceRequiredAndMissingCodeChallengeThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(ClientSettings.builder().requireProofKey(true).build())
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPkceUnsupportedCodeChallengeMethodThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge");
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "unsupported");
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPkceMissingCodeChallengeMethodThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge");
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, PkceParameterNames.CODE_CHALLENGE_METHOD,
+					authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestWithPromptNoneLoginThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+				"none login");
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestWithPromptNoneConsentThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+				"none consent");
+	}
+
+	@Test
+	public void authenticateWhenAuthenticationRequestWithPromptNoneSelectAccountThenThrowOAuth2AuthorizationCodeRequestAuthenticationException() {
+		assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+				"none select_account");
+	}
+
+	private void assertWhenAuthenticationRequestWithInvalidPromptThenThrowOAuth2AuthorizationCodeRequestAuthenticationException(
+			String prompt) {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("prompt", prompt);
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthorizationCodeRequestAuthenticationException.class)
+			.satisfies((ex) -> assertAuthenticationException((OAuth2AuthorizationCodeRequestAuthenticationException) ex,
+					OAuth2ErrorCodes.INVALID_REQUEST, "prompt", authentication.getRedirectUri()));
+	}
+
+	@Test
+	public void authenticateWhenPushedAuthorizationRequestValidThenReturnPushedAuthorizationResponse() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[0];
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE, "code-challenge");
+		additionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), additionalParameters);
+		OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult);
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthenticationValidatorThenUsed() {
+		@SuppressWarnings("unchecked")
+		Consumer<OAuth2AuthorizationCodeRequestAuthenticationContext> authenticationValidator = mock(Consumer.class);
+		this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		String redirectUri = registeredClient.getRedirectUris().toArray(new String[0])[2];
+		OAuth2PushedAuthorizationRequestAuthenticationToken authentication = new OAuth2PushedAuthorizationRequestAuthenticationToken(
+				AUTHORIZATION_URI, registeredClient.getClientId(), clientPrincipal, redirectUri, STATE,
+				registeredClient.getScopes(), null);
+		OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult = (OAuth2PushedAuthorizationRequestAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertPushedAuthorizationResponse(registeredClient, authentication, authenticationResult);
+		verify(authenticationValidator).accept(any());
+	}
+
+	private void assertPushedAuthorizationResponse(RegisteredClient registeredClient,
+			OAuth2PushedAuthorizationRequestAuthenticationToken authentication,
+			OAuth2PushedAuthorizationRequestAuthenticationToken authenticationResult) {
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		assertThat(authorizationRequest.getGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authorizationRequest.getResponseType()).isEqualTo(OAuth2AuthorizationResponseType.CODE);
+		assertThat(authorizationRequest.getAuthorizationUri()).isEqualTo(authentication.getAuthorizationUri());
+		assertThat(authorizationRequest.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authorizationRequest.getRedirectUri()).isEqualTo(authentication.getRedirectUri());
+		assertThat(authorizationRequest.getScopes()).isEqualTo(authentication.getScopes());
+		assertThat(authorizationRequest.getState()).isEqualTo(authentication.getState());
+		assertThat(authorizationRequest.getAdditionalParameters()).isEqualTo(authentication.getAdditionalParameters());
+
+		assertThat(authorization.getRegisteredClientId()).isEqualTo(registeredClient.getId());
+		assertThat(authorization.getPrincipalName()).isEqualTo(authentication.getName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(authorization.<String>getAttribute(OAuth2ParameterNames.STATE)).isNotNull();
+
+		assertThat(authenticationResult.getClientId()).isEqualTo(authorizationRequest.getClientId());
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getAuthorizationUri()).isEqualTo(authorizationRequest.getAuthorizationUri());
+		assertThat(authenticationResult.getRedirectUri()).isEqualTo(authorizationRequest.getRedirectUri());
+		assertThat(authenticationResult.getScopes()).isEqualTo(authorizationRequest.getScopes());
+		assertThat(authenticationResult.getState()).isEqualTo(authorizationRequest.getState());
+		assertThat(authenticationResult.getRequestUri()).isNotNull();
+		assertThat(authenticationResult.getRequestUriExpiresAt()).isNotNull();
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+	}
+
+	private static void assertAuthenticationException(
+			OAuth2AuthorizationCodeRequestAuthenticationException authenticationException, String errorCode,
+			String parameterName, String redirectUri) {
+
+		OAuth2Error error = authenticationException.getError();
+		assertThat(error.getErrorCode()).isEqualTo(errorCode);
+		assertThat(error.getDescription()).contains(parameterName);
+
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthentication = authenticationException
+			.getAuthorizationCodeRequestAuthentication();
+		assertThat(authorizationCodeRequestAuthentication.getRedirectUri()).isEqualTo(redirectUri);
+	}
+
+}

+ 690 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationProviderTests.java

@@ -0,0 +1,690 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+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.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+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.IdTokenClaimNames;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+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.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 org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2AccessTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2RefreshTokenAuthenticationProvider}.
+ *
+ * @author Alexey Nesterov
+ * @author Joe Grandja
+ * @author Anoop Garlapati
+ * @since 0.0.3
+ */
+public class OAuth2RefreshTokenAuthenticationProviderTests {
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private JwtEncoder jwtEncoder;
+
+	private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
+
+	private OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer;
+
+	private OAuth2TokenGenerator<?> tokenGenerator;
+
+	private JwtEncoder dPoPProofJwtEncoder;
+
+	private OAuth2RefreshTokenAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.jwtEncoder = mock(JwtEncoder.class);
+		given(this.jwtEncoder.encode(any())).willReturn(createJwt(Collections.singleton("scope1")));
+		this.jwtCustomizer = mock(OAuth2TokenCustomizer.class);
+		JwtGenerator jwtGenerator = new JwtGenerator(this.jwtEncoder);
+		jwtGenerator.setJwtCustomizer(this.jwtCustomizer);
+		this.accessTokenCustomizer = mock(OAuth2TokenCustomizer.class);
+		OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator();
+		accessTokenGenerator.setAccessTokenCustomizer(this.accessTokenCustomizer);
+		OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
+		OAuth2TokenGenerator<OAuth2Token> delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(jwtGenerator,
+				accessTokenGenerator, refreshTokenGenerator);
+		this.tokenGenerator = spy(new OAuth2TokenGenerator<OAuth2Token>() {
+			@Override
+			public OAuth2Token generate(OAuth2TokenContext context) {
+				return delegatingTokenGenerator.generate(context);
+			}
+		});
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		this.authenticationProvider = new OAuth2RefreshTokenAuthenticationProvider(this.authorizationService,
+				this.tokenGenerator);
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://provider.com")
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
+	}
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2RefreshTokenAuthenticationProvider(null, this.tokenGenerator))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2RefreshTokenAuthenticationProvider(this.authorizationService, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("tokenGenerator cannot be null");
+	}
+
+	@Test
+	public void supportsWhenSupportedAuthenticationThenTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2RefreshTokenAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void supportsWhenUnsupportedAuthenticationThenFalse() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientCredentialsAuthenticationToken.class)).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenValidRefreshTokenThenReturnAccessToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null,
+				additionalParameters);
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer).customize(jwtEncodingContextCaptor.capture());
+		JwtEncodingContext jwtEncodingContext = jwtEncodingContextCaptor.getValue();
+		assertThat(jwtEncodingContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(jwtEncodingContext.<Authentication>getPrincipal())
+			.isEqualTo(authorization.getAttribute(Principal.class.getName()));
+		assertThat(jwtEncodingContext.getAuthorization()).isEqualTo(authorization);
+		assertThat(jwtEncodingContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(jwtEncodingContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(jwtEncodingContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.REFRESH_TOKEN);
+		assertThat(jwtEncodingContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
+		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getRegisteredClient().getId())
+			.isEqualTo(updatedAuthorization.getRegisteredClientId());
+		assertThat(accessTokenAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(accessTokenAuthentication.getAccessToken())
+			.isEqualTo(updatedAuthorization.getAccessToken().getToken());
+		assertThat(updatedAuthorization.getAccessToken()).isNotEqualTo(authorization.getAccessToken());
+		assertThat(accessTokenAuthentication.getRefreshToken())
+			.isEqualTo(updatedAuthorization.getRefreshToken().getToken());
+		// By default, refresh token is reused
+		assertThat(updatedAuthorization.getRefreshToken()).isEqualTo(authorization.getRefreshToken());
+	}
+
+	@Test
+	public void authenticateWhenValidRefreshTokenThenReturnIdToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OidcIdToken authorizedIdToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.subject("subject")
+			.issuedAt(Instant.now())
+			.expiresAt(Instant.now().plusSeconds(60))
+			.claim("sid", "sessionId-1234")
+			.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()))
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(authorizedIdToken)
+			.build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer, times(2)).customize(jwtEncodingContextCaptor.capture());
+		// 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.getAuthorization()).isEqualTo(authorization);
+		assertThat(accessTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(accessTokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(accessTokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.REFRESH_TOKEN);
+		assertThat(accessTokenContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(accessTokenContext.getJwsHeader()).isNotNull();
+		assertThat(accessTokenContext.getClaims()).isNotNull();
+		Map<String, Object> claims = new HashMap<>();
+		accessTokenContext.getClaims().claims(claims::putAll);
+		assertThat(claims).flatExtracting(OAuth2ParameterNames.SCOPE)
+			.containsExactlyInAnyOrder(OidcScopes.OPENID, "scope1");
+		// 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.getAuthorization()).isNotEqualTo(authorization);
+		assertThat(idTokenContext.getAuthorization().getAccessToken()).isNotEqualTo(authorization.getAccessToken());
+		assertThat(idTokenContext.getAuthorizedScopes()).isEqualTo(authorization.getAuthorizedScopes());
+		assertThat(idTokenContext.getTokenType().getValue()).isEqualTo(OidcParameterNames.ID_TOKEN);
+		assertThat(idTokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.REFRESH_TOKEN);
+		assertThat(idTokenContext.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authentication);
+		assertThat(idTokenContext.getJwsHeader()).isNotNull();
+		assertThat(idTokenContext.getClaims()).isNotNull();
+
+		verify(this.jwtEncoder, times(2)).encode(any()); // Access token and ID Token
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getRegisteredClient().getId())
+			.isEqualTo(updatedAuthorization.getRegisteredClientId());
+		assertThat(accessTokenAuthentication.getPrincipal()).isEqualTo(clientPrincipal);
+		assertThat(accessTokenAuthentication.getAccessToken())
+			.isEqualTo(updatedAuthorization.getAccessToken().getToken());
+		assertThat(updatedAuthorization.getAccessToken()).isNotEqualTo(authorization.getAccessToken());
+		OAuth2Authorization.Token<OidcIdToken> idToken = updatedAuthorization.getToken(OidcIdToken.class);
+		assertThat(idToken).isNotNull();
+		assertThat(accessTokenAuthentication.getAdditionalParameters())
+			.containsExactly(entry(OidcParameterNames.ID_TOKEN, idToken.getToken().getTokenValue()));
+		assertThat(accessTokenAuthentication.getRefreshToken())
+			.isEqualTo(updatedAuthorization.getRefreshToken().getToken());
+		// By default, refresh token is reused
+		assertThat(updatedAuthorization.getRefreshToken()).isEqualTo(authorization.getRefreshToken());
+	}
+
+	@Test
+	public void authenticateWhenReuseRefreshTokensFalseThenReturnNewRefreshToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.tokenSettings(TokenSettings.builder().reuseRefreshTokens(false).build())
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+
+		assertThat(accessTokenAuthentication.getRefreshToken())
+			.isEqualTo(updatedAuthorization.getRefreshToken().getToken());
+		assertThat(updatedAuthorization.getRefreshToken()).isNotEqualTo(authorization.getRefreshToken());
+
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.tokenGenerator, times(2)).generate(tokenContextCaptor.capture());
+		// tokenGenerator is first invoked for generating a new access token and then for
+		// generating the refresh token
+		List<OAuth2TokenContext> tokenContexts = tokenContextCaptor.getAllValues();
+		assertThat(tokenContexts).hasSize(2);
+		assertThat(tokenContexts.get(0).getAuthorization().getAccessToken().getToken().getTokenValue())
+			.isEqualTo("access-token");
+		assertThat(tokenContexts.get(1).getAuthorization().getAccessToken().getToken().getTokenValue())
+			.isEqualTo("refreshed-access-token");
+	}
+
+	@Test
+	public void authenticateWhenRequestedScopesAuthorizedThenAccessTokenIncludesScopes() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.scope("scope2")
+			.scope("scope3")
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Set<String> authorizedScopes = authorization.getAuthorizedScopes();
+		Set<String> requestedScopes = new HashSet<>(authorizedScopes);
+		requestedScopes.remove("scope1");
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, requestedScopes, null);
+
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(accessTokenAuthentication.getAccessToken().getScopes()).isEqualTo(requestedScopes);
+	}
+
+	@Test
+	public void authenticateWhenRequestedScopesNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		Set<String> authorizedScopes = authorization.getAuthorizedScopes();
+		Set<String> requestedScopes = new HashSet<>(authorizedScopes);
+		requestedScopes.add("unauthorized");
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, requestedScopes, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+	}
+
+	@Test
+	public void authenticateWhenInvalidRefreshTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken("invalid",
+				clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(registeredClient.getClientId(),
+				registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				"refresh-token", clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				"refresh-token", clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenRefreshTokenIssuedToAnotherClientThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient2,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient2.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthorizedToRefreshTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantTypes((grantTypes) -> grantTypes.remove(AuthorizationGrantType.REFRESH_TOKEN))
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenExpiredRefreshTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2RefreshToken expiredRefreshToken = new OAuth2RefreshToken("expired-refresh-token",
+				Instant.now().minusSeconds(120), Instant.now().minusSeconds(60));
+		authorization = OAuth2Authorization.from(authorization).token(expiredRefreshToken).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenRevokedRefreshTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2RefreshToken refreshToken = new OAuth2RefreshToken("refresh-token", Instant.now().minusSeconds(120),
+				Instant.now().plusSeconds(1000));
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(refreshToken, (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true))
+			.build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		willAnswer((answer) -> {
+			OAuth2TokenContext context = answer.getArgument(0);
+			if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+				return null;
+			}
+			else {
+				return answer.callRealMethod();
+			}
+		}).given(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription()).contains("The token generator failed to generate the access token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenRefreshTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.tokenSettings(TokenSettings.builder().reuseRefreshTokens(false).build())
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		willAnswer((answer) -> {
+			OAuth2TokenContext context = answer.getArgument(0);
+			if (OAuth2TokenType.REFRESH_TOKEN.equals(context.getTokenType())) {
+				return null;
+			}
+			else {
+				return answer.callRealMethod();
+			}
+		}).given(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription())
+					.contains("The token generator failed to generate the refresh token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenIdTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		willAnswer((answer) -> {
+			OAuth2TokenContext context = answer.getArgument(0);
+			if (OidcParameterNames.ID_TOKEN.equals(context.getTokenType().getValue())) {
+				return null;
+			}
+			else {
+				return answer.callRealMethod();
+			}
+		}).given(this.tokenGenerator).generate(any());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription()).contains("The token generator failed to generate the ID token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenFormatReferenceThenAccessTokenGeneratorCalled() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(TokenSettings.builder()
+						.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
+						.build())
+				.build();
+		// @formatter:on
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				eq(OAuth2TokenType.REFRESH_TOKEN)))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal, null, null);
+
+		this.authenticationProvider.authenticate(authentication);
+
+		verify(this.accessTokenCustomizer).customize(any());
+	}
+
+	private static Jwt createJwt(Set<String> scope) {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		return Jwt.withTokenValue("refreshed-access-token")
+			.header(JoseHeaderNames.ALG, SignatureAlgorithm.RS256.getName())
+			.issuedAt(issuedAt)
+			.expiresAt(expiresAt)
+			.claim(OAuth2ParameterNames.SCOPE, scope)
+			.build();
+	}
+
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+}

+ 81 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2RefreshTokenAuthenticationTokenTests.java

@@ -0,0 +1,81 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2RefreshTokenAuthenticationToken}.
+ *
+ * @author Alexey Nesterov
+ * @since 0.0.3
+ */
+public class OAuth2RefreshTokenAuthenticationTokenTests {
+
+	private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private Set<String> scopes = Collections.singleton("scope1");
+
+	private Map<String, Object> additionalParameters = Collections.singletonMap("param1", "value1");
+
+	@Test
+	public void constructorWhenRefreshTokenNullOrEmptyThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2RefreshTokenAuthenticationToken(null, this.clientPrincipal, this.scopes,
+				this.additionalParameters))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("refreshToken cannot be empty");
+		assertThatThrownBy(() -> new OAuth2RefreshTokenAuthenticationToken("", this.clientPrincipal, this.scopes,
+				this.additionalParameters))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("refreshToken cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2RefreshTokenAuthenticationToken("refresh-token", null, this.scopes,
+				this.additionalParameters))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenScopesProvidedThenCreated() {
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				"refresh-token", this.clientPrincipal, this.scopes, this.additionalParameters);
+		assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.REFRESH_TOKEN);
+		assertThat(authentication.getRefreshToken()).isEqualTo("refresh-token");
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getScopes()).isEqualTo(this.scopes);
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+}

+ 48 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeActorTests.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.authentication;
+
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OAuth2TokenExchangeActor}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2TokenExchangeActorTests {
+
+	@Test
+	public void constructorWhenClaimsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2TokenExchangeActor(null))
+				.withMessage("claims cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenRequiredParametersThenCreated() {
+		Map<String, Object> claims = Map.of("claim1", "value1");
+		OAuth2TokenExchangeActor actor = new OAuth2TokenExchangeActor(claims);
+		assertThat(actor.getClaims()).isEqualTo(claims);
+	}
+
+}

+ 788 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationProviderTests.java

@@ -0,0 +1,788 @@
+/*
+ * Copyright 2020-2025 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.authentication;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+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.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+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.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+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.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 org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimNames;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link OAuth2TokenExchangeAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2TokenExchangeAuthenticationProviderTests {
+
+	private static final Set<String> RESOURCES = Set.of("https://mydomain.com/resource1",
+			"https://mydomain.com/resource2");
+
+	private static final Set<String> AUDIENCES = Set.of("audience1", "audience2");
+
+	private static final String SUBJECT_TOKEN = "EfYu_0jEL";
+
+	private static final String ACTOR_TOKEN = "JlNE_xR1f";
+
+	private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token";
+
+	private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt";
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2TokenGenerator<OAuth2Token> tokenGenerator;
+
+	private JwtEncoder dPoPProofJwtEncoder;
+
+	private OAuth2TokenExchangeAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	@SuppressWarnings("unchecked")
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.tokenGenerator = mock(OAuth2TokenGenerator.class);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		this.dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		this.authenticationProvider = new OAuth2TokenExchangeAuthenticationProvider(this.authorizationService,
+				this.tokenGenerator);
+		mockAuthorizationServerContext();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2TokenExchangeAuthenticationProvider(null, this.tokenGenerator))
+				.withMessage("authorizationService cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2TokenExchangeAuthenticationProvider(this.authorizationService, null))
+				.withMessage("tokenGenerator cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2TokenExchangeAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2TokenExchangeAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken("client-1",
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null, null);
+		Authentication authentication = new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN,
+				ACCESS_TOKEN_TYPE_VALUE, clientPrincipal, null, null, RESOURCES, AUDIENCES, null, null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenInvalidGrantTypeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.UNAUTHORIZED_CLIENT);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenInvalidRequestedTokenTypeThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+				.tokenSettings(TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.REFERENCE).build())
+				.build();
+		// @formatter:on
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+	}
+
+	@Test
+	public void authenticateWhenSubjectTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenSubjectTokenNotActiveThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createExpiredAccessToken(SUBJECT_TOKEN))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenSubjectTokenTypeJwtAndSubjectTokenFormatReferenceThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createJwtRequest(registeredClient);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(SUBJECT_TOKEN), withTokenFormat(OAuth2TokenFormat.REFERENCE))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenSubjectPrincipalNullThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		// @formatter:off
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN))
+				.attributes((attributes) -> attributes.remove(Principal.class.getName()))
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class))).willReturn(authorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenActorTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(SUBJECT_TOKEN))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, (OAuth2Authorization) null);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenActorTokenNotActiveThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(SUBJECT_TOKEN))
+			.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createExpiredAccessToken(ACTOR_TOKEN))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenActorTokenTypeJwtAndActorTokenFormatReferenceThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createJwtRequest(registeredClient);
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(SUBJECT_TOKEN), withTokenFormat(OAuth2TokenFormat.SELF_CONTAINED))
+			.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(ACTOR_TOKEN), withTokenFormat(OAuth2TokenFormat.REFERENCE))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenMayActAndActorIssClaimNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		Map<String, String> authorizedActorClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer",
+				OAuth2TokenClaimNames.SUB, "actor");
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", authorizedActorClaims)))
+				.build();
+		// @formatter:on
+		Map<String, Object> actorTokenClaims = Map.of(OAuth2TokenClaimNames.ISS, "invalid-issuer",
+				OAuth2TokenClaimNames.SUB, "actor");
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(ACTOR_TOKEN), withClaims(actorTokenClaims))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenMayActAndActorSubClaimNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		Map<String, String> authorizedActorClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer",
+				OAuth2TokenClaimNames.SUB, "actor");
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", authorizedActorClaims)))
+				.build();
+		// @formatter:on
+		Map<String, Object> actorTokenClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer", OAuth2TokenClaimNames.SUB,
+				"invalid-actor");
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(ACTOR_TOKEN), withClaims(actorTokenClaims))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenMayActAndImpersonationThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient);
+		Map<String, String> authorizedActorClaims = Map.of(OAuth2TokenClaimNames.ISS, "issuer",
+				OAuth2TokenClaimNames.SUB, "actor");
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", authorizedActorClaims)))
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopeInRequestThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient,
+				Set.of("invalid"));
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(SUBJECT_TOKEN))
+			.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(ACTOR_TOKEN))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenInvalidScopeInSubjectAuthorizationThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient, Set.of());
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(SUBJECT_TOKEN))
+			.authorizedScopes(Set.of("invalid"))
+			.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(createAccessToken(ACTOR_TOKEN))
+			.build();
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		// @formatter:off
+		assertThatExceptionOfType(OAuth2AuthenticationException.class)
+				.isThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+				.extracting(OAuth2AuthenticationException::getError)
+				.extracting(OAuth2Error::getErrorCode)
+				.isEqualTo(OAuth2ErrorCodes.INVALID_SCOPE);
+		// @formatter:on
+
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verifyNoMoreInteractions(this.authorizationService);
+		verifyNoInteractions(this.tokenGenerator);
+	}
+
+	@Test
+	public void authenticateWhenNoActorTokenAndValidTokenExchangeThenReturnAccessTokenForImpersonation() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		Map<String, Object> additionalParameters = new HashMap<>();
+		additionalParameters.put("dpop_proof", generateDPoPProof("http://localhost/oauth2/token"));
+		additionalParameters.put("dpop_method", "POST");
+		additionalParameters.put("dpop_target_uri", "http://localhost/oauth2/token");
+		OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken(
+				JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, clientPrincipal, null, null, RESOURCES,
+				AUDIENCES, registeredClient.getScopes(), additionalParameters);
+		TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER");
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN))
+				.attribute(Principal.class.getName(), userPrincipal)
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization);
+		OAuth2AccessToken accessToken = createAccessToken("token-value");
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken);
+		OAuth2AccessTokenAuthenticationToken authenticationResult = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken);
+		assertThat(authenticationResult.getRefreshToken()).isNull();
+		assertThat(authenticationResult.getAdditionalParameters()).hasSize(1);
+		assertThat(authenticationResult.getAdditionalParameters().get(OAuth2ParameterNames.ISSUED_TOKEN_TYPE))
+			.isEqualTo(JWT_TOKEN_TYPE_VALUE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.tokenGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.getAuthorization()).isEqualTo(subjectAuthorization);
+		assertThat(tokenContext.<Authentication>getPrincipal()).isSameAs(userPrincipal);
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(tokenContext.<Jwt>get(OAuth2TokenContext.DPOP_PROOF_KEY)).isNotNull();
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(authorization.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(authorization.<Authentication>getAttribute(Principal.class.getName())).isSameAs(userPrincipal);
+		assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken);
+		assertThat(authorization.getRefreshToken()).isNull();
+	}
+
+	@Test
+	public void authenticateWhenNoActorTokenAndPreviousActorThenReturnAccessTokenForImpersonation() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createImpersonationRequest(registeredClient);
+		TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER");
+		OAuth2TokenExchangeActor previousActor = new OAuth2TokenExchangeActor(
+				Map.of(OAuth2TokenClaimNames.ISS, "issuer1", OAuth2TokenClaimNames.SUB, "actor"));
+		OAuth2TokenExchangeCompositeAuthenticationToken subjectPrincipal = new OAuth2TokenExchangeCompositeAuthenticationToken(
+				userPrincipal, List.of(previousActor));
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN))
+				.attribute(Principal.class.getName(), subjectPrincipal)
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization);
+		OAuth2AccessToken accessToken = createAccessToken("token-value");
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken);
+		OAuth2AccessTokenAuthenticationToken authenticationResult = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken);
+		assertThat(authenticationResult.getRefreshToken()).isNull();
+		assertThat(authenticationResult.getAdditionalParameters()).hasSize(1);
+		assertThat(authenticationResult.getAdditionalParameters().get(OAuth2ParameterNames.ISSUED_TOKEN_TYPE))
+			.isEqualTo(JWT_TOKEN_TYPE_VALUE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.tokenGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.getAuthorization()).isEqualTo(subjectAuthorization);
+		assertThat(tokenContext.<Authentication>getPrincipal()).isSameAs(userPrincipal);
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(authorization.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(authorization.<Authentication>getAttribute(Principal.class.getName())).isSameAs(userPrincipal);
+		assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken);
+		assertThat(authorization.getRefreshToken()).isNull();
+	}
+
+	@Test
+	public void authenticateWhenActorTokenAndValidTokenExchangeThenReturnAccessTokenForDelegation() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		OAuth2TokenExchangeAuthenticationToken authentication = createDelegationRequest(registeredClient);
+		TestingAuthenticationToken userPrincipal = new TestingAuthenticationToken("user", null, "ROLE_USER");
+		OAuth2TokenExchangeActor actor1 = new OAuth2TokenExchangeActor(
+				Map.of(OAuth2TokenClaimNames.ISS, "issuer1", OAuth2TokenClaimNames.SUB, "actor1"));
+		OAuth2TokenExchangeActor actor2 = new OAuth2TokenExchangeActor(
+				Map.of(OAuth2TokenClaimNames.ISS, "issuer2", OAuth2TokenClaimNames.SUB, "actor2"));
+		OAuth2TokenExchangeCompositeAuthenticationToken subjectPrincipal = new OAuth2TokenExchangeCompositeAuthenticationToken(
+				userPrincipal, List.of(actor1));
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.token(createAccessToken(SUBJECT_TOKEN), withClaims(Map.of("may_act", actor2.getClaims())))
+				.attribute(Principal.class.getName(), subjectPrincipal)
+				.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+				.principalName(actor2.getSubject())
+				.token(createAccessToken(ACTOR_TOKEN), withClaims(actor2.getClaims()))
+				.build();
+		// @formatter:on
+		given(this.authorizationService.findByToken(anyString(), any(OAuth2TokenType.class)))
+			.willReturn(subjectAuthorization, actorAuthorization);
+		OAuth2AccessToken accessToken = createAccessToken("token-value");
+		given(this.tokenGenerator.generate(any(OAuth2TokenContext.class))).willReturn(accessToken);
+		OAuth2AccessTokenAuthenticationToken authenticationResult = (OAuth2AccessTokenAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(authentication.getPrincipal());
+		assertThat(authenticationResult.getAccessToken()).isEqualTo(accessToken);
+		assertThat(authenticationResult.getRefreshToken()).isNull();
+		assertThat(authenticationResult.getAdditionalParameters()).hasSize(1);
+		assertThat(authenticationResult.getAdditionalParameters().get(OAuth2ParameterNames.ISSUED_TOKEN_TYPE))
+			.isEqualTo(JWT_TOKEN_TYPE_VALUE);
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.authorizationService).findByToken(SUBJECT_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.authorizationService).findByToken(ACTOR_TOKEN, OAuth2TokenType.ACCESS_TOKEN);
+		verify(this.tokenGenerator).generate(tokenContextCaptor.capture());
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+		verifyNoMoreInteractions(this.authorizationService, this.tokenGenerator);
+
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(tokenContext.getAuthorization()).isEqualTo(subjectAuthorization);
+		assertThat(tokenContext.getAuthorizationServerContext()).isNotNull();
+		assertThat(tokenContext.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(tokenContext.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(tokenContext.<Authentication>getAuthorizationGrant()).isEqualTo(authentication);
+		assertThat(tokenContext.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+
+		OAuth2TokenExchangeCompositeAuthenticationToken tokenContextPrincipal = tokenContext.getPrincipal();
+		assertThat(tokenContextPrincipal.getSubject()).isSameAs(subjectPrincipal.getSubject());
+		assertThat(tokenContextPrincipal.getActors()).containsExactly(actor2, actor1);
+
+		OAuth2Authorization authorization = authorizationCaptor.getValue();
+		assertThat(authorization.getPrincipalName()).isEqualTo(subjectAuthorization.getPrincipalName());
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(authorization.getAuthorizedScopes()).isEqualTo(authentication.getScopes());
+		assertThat(authorization.getAccessToken().getToken()).isEqualTo(accessToken);
+		assertThat(authorization.getRefreshToken()).isNull();
+
+		OAuth2TokenExchangeCompositeAuthenticationToken authorizationPrincipal = authorization
+			.getAttribute(Principal.class.getName());
+		assertThat(authorizationPrincipal).isNotNull();
+		assertThat(authorizationPrincipal.getSubject()).isSameAs(subjectPrincipal.getSubject());
+		assertThat(authorizationPrincipal.getActors()).containsExactly(actor2, actor1);
+	}
+
+	private static void mockAuthorizationServerContext() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		TestAuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, () -> "https://provider.com");
+		AuthorizationServerContextHolder.setContext(authorizationServerContext);
+	}
+
+	private static OAuth2TokenExchangeAuthenticationToken createDelegationRequest(RegisteredClient registeredClient) {
+		return createDelegationRequest(registeredClient, registeredClient.getScopes());
+	}
+
+	private static OAuth2TokenExchangeAuthenticationToken createDelegationRequest(RegisteredClient registeredClient,
+			Set<String> requestedScopes) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		return new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE,
+				clientPrincipal, ACTOR_TOKEN, ACCESS_TOKEN_TYPE_VALUE, RESOURCES, AUDIENCES, requestedScopes, null);
+	}
+
+	private static OAuth2TokenExchangeAuthenticationToken createImpersonationRequest(
+			RegisteredClient registeredClient) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		return new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE,
+				clientPrincipal, null, null, RESOURCES, AUDIENCES, registeredClient.getScopes(), null);
+	}
+
+	private static OAuth2TokenExchangeAuthenticationToken createJwtRequest(RegisteredClient registeredClient) {
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, null);
+		return new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, JWT_TOKEN_TYPE_VALUE,
+				clientPrincipal, ACTOR_TOKEN, JWT_TOKEN_TYPE_VALUE, RESOURCES, AUDIENCES, registeredClient.getScopes(),
+				null);
+	}
+
+	private static OAuth2AccessToken createAccessToken(String tokenValue) {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES);
+		return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, issuedAt, expiresAt);
+	}
+
+	private static OAuth2AccessToken createExpiredAccessToken(String tokenValue) {
+		Instant issuedAt = Instant.now().minus(45, ChronoUnit.MINUTES);
+		Instant expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES);
+		return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, issuedAt, expiresAt);
+	}
+
+	private static Consumer<Map<String, Object>> withClaims(Map<String, Object> claims) {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, claims);
+	}
+
+	private static Consumer<Map<String, Object>> withTokenFormat(OAuth2TokenFormat tokenFormat) {
+		return (metadata) -> metadata.put(OAuth2TokenFormat.class.getName(), tokenFormat.getValue());
+	}
+
+	private String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = this.dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+}

+ 144 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeAuthenticationTokenTests.java

@@ -0,0 +1,144 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.authentication;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
+import org.springframework.security.oauth2.server.authorization.client.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2TokenExchangeAuthenticationToken}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2TokenExchangeAuthenticationTokenTests {
+
+	private static final Set<String> RESOURCES = Set.of("https://mydomain.com/resource1",
+			"https://mydomain.com/resource2");
+
+	private static final Set<String> AUDIENCES = Set.of("audience1", "audience2");
+
+	private static final String SUBJECT_TOKEN = "EfYu_0jEL";
+
+	private static final String ACTOR_TOKEN = "JlNE_xR1f";
+
+	private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token";
+
+	private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt";
+
+	private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private Set<String> scopes = Collections.singleton("scope1");
+
+	private Map<String, Object> additionalParameters = Collections.singletonMap("param1", "value1");
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(null, null, null, null, null, null, null, null, null, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("clientPrincipal cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenRequestedTokenTypeNullOrEmptyThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(null, null, null, this.clientPrincipal, null, null, null, null, null, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("requestedTokenType cannot be empty");
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken("", null, null, this.clientPrincipal, null, null, null, null, null, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("requestedTokenType cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenSubjectTokenNullOrEmptyThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, null, null, this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("subjectToken cannot be empty");
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, "", null, this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("subjectToken cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenSubjectTokenTypeNullOrEmptyThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, null, this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("subjectTokenType cannot be empty");
+		assertThatThrownBy(() -> new OAuth2TokenExchangeAuthenticationToken(JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, "", this.clientPrincipal, null, null, null, null, this.scopes, this.additionalParameters))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("subjectTokenType cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenRequiredParametersProvidedThenCreated() {
+		OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken(
+				JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, this.clientPrincipal, null, null, null,
+				null, null, this.additionalParameters);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(authentication.getRequestedTokenType()).isEqualTo(JWT_TOKEN_TYPE_VALUE);
+		assertThat(authentication.getSubjectToken()).isEqualTo(SUBJECT_TOKEN);
+		assertThat(authentication.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE);
+		assertThat(authentication.getActorToken()).isNull();
+		assertThat(authentication.getActorTokenType()).isNull();
+		assertThat(authentication.getResources()).isEmpty();
+		assertThat(authentication.getAudiences()).isEmpty();
+		assertThat(authentication.getScopes()).isEmpty();
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+	@Test
+	public void constructorWhenAllParametersProvidedThenCreated() {
+		OAuth2TokenExchangeAuthenticationToken authentication = new OAuth2TokenExchangeAuthenticationToken(
+				JWT_TOKEN_TYPE_VALUE, SUBJECT_TOKEN, ACCESS_TOKEN_TYPE_VALUE, this.clientPrincipal, ACTOR_TOKEN,
+				ACCESS_TOKEN_TYPE_VALUE, RESOURCES, AUDIENCES, this.scopes, this.additionalParameters);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getGrantType()).isEqualTo(AuthorizationGrantType.TOKEN_EXCHANGE);
+		assertThat(authentication.getRequestedTokenType()).isEqualTo(JWT_TOKEN_TYPE_VALUE);
+		assertThat(authentication.getSubjectToken()).isEqualTo(SUBJECT_TOKEN);
+		assertThat(authentication.getSubjectTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE);
+		assertThat(authentication.getActorToken()).isEqualTo(ACTOR_TOKEN);
+		assertThat(authentication.getActorTokenType()).isEqualTo(ACCESS_TOKEN_TYPE_VALUE);
+		assertThat(authentication.getResources()).isEqualTo(RESOURCES);
+		assertThat(authentication.getAudiences()).isEqualTo(AUDIENCES);
+		assertThat(authentication.getScopes()).isEqualTo(this.scopes);
+		assertThat(authentication.getAdditionalParameters()).isEqualTo(this.additionalParameters);
+	}
+
+}

+ 66 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenExchangeCompositeAuthenticationTokenTests.java

@@ -0,0 +1,66 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.authentication;
+
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OAuth2TokenExchangeCompositeAuthenticationToken}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2TokenExchangeCompositeAuthenticationTokenTests {
+
+	@Test
+	public void constructorWhenSubjectNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2TokenExchangeCompositeAuthenticationToken(null, null))
+				.withMessage("subject cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenActorsNullThenThrowIllegalArgumentException() {
+		TestingAuthenticationToken subject = new TestingAuthenticationToken("subject", null);
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new OAuth2TokenExchangeCompositeAuthenticationToken(subject, null))
+				.withMessage("actors cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void constructorWhenRequiredParametersProvidedThenCreated() {
+		TestingAuthenticationToken subject = new TestingAuthenticationToken("subject", null);
+		OAuth2TokenExchangeActor actor1 = new OAuth2TokenExchangeActor(Map.of("claim1", "value1"));
+		OAuth2TokenExchangeActor actor2 = new OAuth2TokenExchangeActor(Map.of("claim2", "value2"));
+		List<OAuth2TokenExchangeActor> actors = List.of(actor1, actor2);
+		OAuth2TokenExchangeCompositeAuthenticationToken authentication = new OAuth2TokenExchangeCompositeAuthenticationToken(
+				subject, actors);
+		assertThat(authentication.getSubject()).isEqualTo(subject);
+		assertThat(authentication.getActors()).isEqualTo(actors);
+	}
+
+}

+ 304 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationProviderTests.java

@@ -0,0 +1,304 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.authentication;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenIntrospection;
+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.token.OAuth2TokenClaimNames;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsSet;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionAuthenticationProvider}.
+ *
+ * @author Gerardo Roza
+ * @author Joe Grandja
+ */
+public class OAuth2TokenIntrospectionAuthenticationProviderTests {
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2TokenIntrospectionAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OAuth2TokenIntrospectionAuthenticationProvider(
+				this.registeredClientRepository, this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenIntrospectionAuthenticationProvider(null, this.authorizationService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenIntrospectionAuthenticationProvider(this.registeredClientRepository, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2TokenIntrospectionAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2TokenIntrospectionAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(registeredClient.getClientId(),
+				registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, null, null);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenInvalidTokenThenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				"token", clientPrincipal, null, null);
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(authentication.getToken()), isNull());
+		assertThat(authenticationResult.isAuthenticated()).isFalse();
+		assertThat(authenticationResult.getTokenClaims().getClaims()).hasSize(1);
+		assertThat(authenticationResult.getTokenClaims().isActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenTokenInvalidatedThenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		authorization = OAuth2Authorization.from(authorization).invalidate(accessToken).build();
+		given(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+			.willReturn(authorization);
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, null, null);
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(authentication.getToken()), isNull());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getTokenClaims().getClaims()).hasSize(1);
+		assertThat(authenticationResult.getTokenClaims().isActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenTokenExpiredThenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Instant issuedAt = Instant.now().minus(Duration.ofHours(1));
+		Instant expiresAt = Instant.now().minus(Duration.ofMinutes(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				issuedAt, expiresAt);
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(accessToken)
+			.build();
+		given(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+			.willReturn(authorization);
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, null, null);
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(authentication.getToken()), isNull());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getTokenClaims().getClaims()).hasSize(1);
+		assertThat(authenticationResult.getTokenClaims().isActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenTokenBeforeUseThenNotActive() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		Instant issuedAt = Instant.now();
+		Instant notBefore = issuedAt.plus(Duration.ofMinutes(5));
+		Instant expiresAt = issuedAt.plus(Duration.ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				issuedAt, expiresAt);
+		Map<String, Object> accessTokenClaims = Collections.singletonMap(OAuth2TokenClaimNames.NBF, notBefore);
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, accessToken, accessTokenClaims)
+			.build();
+		given(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+			.willReturn(authorization);
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, null, null);
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(authentication.getToken()), isNull());
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getTokenClaims().getClaims()).hasSize(1);
+		assertThat(authenticationResult.getTokenClaims().isActive()).isFalse();
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenActive() {
+		RegisteredClient authorizedClient = TestRegisteredClients.registeredClient().build();
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(Duration.ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				issuedAt, expiresAt, new HashSet<>(Arrays.asList("scope1", "scope2")));
+
+		// @formatter:off
+		OAuth2TokenClaimsSet claimsSet = OAuth2TokenClaimsSet.builder()
+				.issuer("https://provider.com")
+				.subject("subject")
+				.audience(Collections.singletonList(authorizedClient.getClientId()))
+				.issuedAt(issuedAt)
+				.notBefore(issuedAt)
+				.expiresAt(expiresAt)
+				.id("id")
+				.claim(OAuth2TokenIntrospectionClaimNames.SCOPE, accessToken.getScopes())
+				.claim("custom-claim", "custom-value")
+				.build();
+		// @formatter:on
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(authorizedClient, accessToken, claimsSet.getClaims())
+			.build();
+		given(this.authorizationService.findByToken(eq(accessToken.getTokenValue()), isNull()))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorizedClient.getId()))).willReturn(authorizedClient);
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, null, null);
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(authentication.getToken()), isNull());
+		verify(this.registeredClientRepository).findById(eq(authorizedClient.getId()));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		OAuth2TokenIntrospection tokenClaims = authenticationResult.getTokenClaims();
+		assertThat(tokenClaims.isActive()).isTrue();
+		assertThat(tokenClaims.getClientId()).isEqualTo(authorizedClient.getClientId());
+		assertThat(tokenClaims.getIssuedAt()).isEqualTo(accessToken.getIssuedAt());
+		assertThat(tokenClaims.getExpiresAt()).isEqualTo(accessToken.getExpiresAt());
+		assertThat(tokenClaims.getTokenType()).isEqualTo(accessToken.getTokenType().getValue());
+		assertThat(tokenClaims.getNotBefore()).isEqualTo(claimsSet.getNotBefore());
+		assertThat(tokenClaims.getSubject()).isEqualTo(claimsSet.getSubject());
+		assertThat(tokenClaims.getAudience()).containsExactlyInAnyOrderElementsOf(claimsSet.getAudience());
+		assertThat(tokenClaims.getIssuer()).isEqualTo(claimsSet.getIssuer());
+		assertThat(tokenClaims.getId()).isEqualTo(claimsSet.getId());
+		assertThat(tokenClaims.getScopes()).containsExactlyInAnyOrderElementsOf(accessToken.getScopes());
+		assertThat(tokenClaims.<String>getClaim("custom-claim")).isEqualTo("custom-value");
+	}
+
+	@Test
+	public void authenticateWhenValidRefreshTokenThenActive() {
+		RegisteredClient authorizedClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization().build();
+		OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
+		given(this.authorizationService.findByToken(eq(refreshToken.getTokenValue()), isNull()))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorizedClient.getId()))).willReturn(authorizedClient);
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				refreshToken.getTokenValue(), clientPrincipal, null, null);
+		OAuth2TokenIntrospectionAuthenticationToken authenticationResult = (OAuth2TokenIntrospectionAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(authentication.getToken()), isNull());
+		verify(this.registeredClientRepository).findById(eq(authorizedClient.getId()));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		OAuth2TokenIntrospection tokenClaims = authenticationResult.getTokenClaims();
+		assertThat(tokenClaims.getClaims()).hasSize(4);
+		assertThat(tokenClaims.isActive()).isTrue();
+		assertThat(tokenClaims.getClientId()).isEqualTo(authorizedClient.getClientId());
+		assertThat(tokenClaims.getIssuedAt()).isEqualTo(refreshToken.getIssuedAt());
+		assertThat(tokenClaims.getExpiresAt()).isEqualTo(refreshToken.getExpiresAt());
+	}
+
+}

+ 115 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenIntrospectionAuthenticationTokenTests.java

@@ -0,0 +1,115 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenIntrospection;
+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.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionAuthenticationToken}.
+ *
+ * @author Gerardo Roza
+ * @author Joe Grandja
+ */
+public class OAuth2TokenIntrospectionAuthenticationTokenTests {
+
+	private String token = "token";
+
+	private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private OAuth2TokenIntrospection tokenClaims = OAuth2TokenIntrospection.builder(true).build();
+
+	@Test
+	public void constructorWhenTokenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenIntrospectionAuthenticationToken(null, this.clientPrincipal, null, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("token cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenIntrospectionAuthenticationToken(this.token, null, null, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthenticatedAndTokenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenIntrospectionAuthenticationToken(null, this.clientPrincipal, this.tokenClaims))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("token cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenAuthenticatedAndClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenIntrospectionAuthenticationToken(this.token, null, this.tokenClaims))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthenticatedAndTokenClaimsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenIntrospectionAuthenticationToken(this.token, this.clientPrincipal, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("tokenClaims cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenProvidedThenCreated() {
+		Map<String, Object> additionalParameters = Collections.singletonMap("custom-param", "custom-value");
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				this.token, this.clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue(), additionalParameters);
+		assertThat(authentication.getToken()).isEqualTo(this.token);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getTokenTypeHint()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN.getValue());
+		assertThat(authentication.getAdditionalParameters()).containsExactlyInAnyOrderEntriesOf(additionalParameters);
+		assertThat(authentication.getTokenClaims()).isNotNull();
+		assertThat(authentication.getTokenClaims().isActive()).isFalse();
+		assertThat(authentication.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void constructorWhenTokenClaimsProvidedThenCreated() {
+		OAuth2TokenIntrospectionAuthenticationToken authentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				this.token, this.clientPrincipal, this.tokenClaims);
+		assertThat(authentication.getToken()).isEqualTo(this.token);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getTokenTypeHint()).isNull();
+		assertThat(authentication.getAdditionalParameters()).isEmpty();
+		assertThat(authentication.getTokenClaims()).isEqualTo(this.tokenClaims);
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+}

+ 193 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationProviderTests.java

@@ -0,0 +1,193 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+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.TestRegisteredClients;
+
+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.eq;
+import static org.mockito.ArgumentMatchers.isNull;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2TokenRevocationAuthenticationProvider}.
+ *
+ * @author Vivek Babu
+ * @author Joe Grandja
+ */
+public class OAuth2TokenRevocationAuthenticationProviderTests {
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OAuth2TokenRevocationAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OAuth2TokenRevocationAuthenticationProvider(this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationProvider(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2TokenRevocationAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2TokenRevocationAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotOAuth2ClientAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		TestingAuthenticationToken clientPrincipal = new TestingAuthenticationToken(registeredClient.getClientId(),
+				registeredClient.getClientSecret());
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken("token",
+				clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenClientPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.CLIENT_SECRET_BASIC,
+				registeredClient.getClientSecret(), null);
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken("token",
+				clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenInvalidTokenThenNotRevoked() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken("token",
+				clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+		OAuth2TokenRevocationAuthenticationToken authenticationResult = (OAuth2TokenRevocationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isFalse();
+		verify(this.authorizationService, never()).save(any());
+	}
+
+	@Test
+	public void authenticateWhenTokenIssuedToAnotherClientThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(TestRegisteredClients.registeredClient2().build())
+			.build();
+		given(this.authorizationService.findByToken(eq("token"), isNull())).willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken("token",
+				clientPrincipal, OAuth2TokenType.ACCESS_TOKEN.getValue());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+	}
+
+	@Test
+	public void authenticateWhenValidRefreshTokenThenRevoked() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getRefreshToken().getToken().getTokenValue()),
+				isNull()))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken(
+				authorization.getRefreshToken().getToken().getTokenValue(), clientPrincipal,
+				OAuth2TokenType.REFRESH_TOKEN.getValue());
+
+		OAuth2TokenRevocationAuthenticationToken authenticationResult = (OAuth2TokenRevocationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = updatedAuthorization.getRefreshToken();
+		assertThat(refreshToken.isInvalidated()).isTrue();
+		OAuth2Authorization.Token<OAuth2AccessToken> accessToken = updatedAuthorization.getAccessToken();
+		assertThat(accessToken.isInvalidated()).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenRevoked() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(authorization.getAccessToken().getToken().getTokenValue()),
+				isNull()))
+			.willReturn(authorization);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken(
+				authorization.getAccessToken().getToken().getTokenValue(), clientPrincipal,
+				OAuth2TokenType.ACCESS_TOKEN.getValue());
+
+		OAuth2TokenRevocationAuthenticationToken authenticationResult = (OAuth2TokenRevocationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+		verify(this.authorizationService).save(authorizationCaptor.capture());
+
+		OAuth2Authorization updatedAuthorization = authorizationCaptor.getValue();
+		OAuth2Authorization.Token<OAuth2AccessToken> accessToken = updatedAuthorization.getAccessToken();
+		assertThat(accessToken.isInvalidated()).isTrue();
+		OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = updatedAuthorization.getRefreshToken();
+		assertThat(refreshToken.isInvalidated()).isFalse();
+	}
+
+}

+ 103 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/OAuth2TokenRevocationAuthenticationTokenTests.java

@@ -0,0 +1,103 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.time.Duration;
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+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.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2TokenRevocationAuthenticationToken}.
+ *
+ * @author Vivek Babu
+ * @author Joe Grandja
+ */
+public class OAuth2TokenRevocationAuthenticationTokenTests {
+
+	private String token = "token";
+
+	private RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+	private OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(this.registeredClient,
+			ClientAuthenticationMethod.CLIENT_SECRET_BASIC, this.registeredClient.getClientSecret());
+
+	private String tokenTypeHint = OAuth2TokenType.ACCESS_TOKEN.getValue();
+
+	private OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, this.token,
+			Instant.now(), Instant.now().plus(Duration.ofHours(1)));
+
+	@Test
+	public void constructorWhenTokenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(
+				() -> new OAuth2TokenRevocationAuthenticationToken(null, this.clientPrincipal, this.tokenTypeHint))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("token cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken(this.token, null, this.tokenTypeHint))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenRevokedTokenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken(null, this.clientPrincipal))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("revokedToken cannot be null");
+	}
+
+	@Test
+	public void constructorWhenRevokedTokenAndClientPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new OAuth2TokenRevocationAuthenticationToken(this.accessToken, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("clientPrincipal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenProvidedThenCreated() {
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken(
+				this.token, this.clientPrincipal, this.tokenTypeHint);
+		assertThat(authentication.getToken()).isEqualTo(this.token);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getTokenTypeHint()).isEqualTo(this.tokenTypeHint);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void constructorWhenRevokedTokenProvidedThenCreated() {
+		OAuth2TokenRevocationAuthenticationToken authentication = new OAuth2TokenRevocationAuthenticationToken(
+				this.accessToken, this.clientPrincipal);
+		assertThat(authentication.getToken()).isEqualTo(this.accessToken.getTokenValue());
+		assertThat(authentication.getPrincipal()).isEqualTo(this.clientPrincipal);
+		assertThat(authentication.getTokenTypeHint()).isNull();
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+}

+ 305 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/PublicClientAuthenticationProviderTests.java

@@ -0,0 +1,305 @@
+/*
+ * Copyright 2020-2022 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.authentication;
+
+import java.util.HashMap;
+import java.util.Map;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link PublicClientAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+public class PublicClientAuthenticationProviderTests {
+
+	// See RFC 7636: Appendix B. Example for the S256 code_challenge_method
+	// https://tools.ietf.org/html/rfc7636#appendix-B
+	private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+	private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+	private static final String AUTHORIZATION_CODE = "code";
+
+	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private PublicClientAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new PublicClientAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new PublicClientAuthenticationProvider(null, this.authorizationService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new PublicClientAuthenticationProvider(this.registeredClientRepository, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2ClientAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId() + "-invalid", ClientAuthenticationMethod.NONE, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
+			});
+	}
+
+	@Test
+	public void authenticateWhenUnsupportedClientAuthenticationMethodThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("authentication_method");
+			});
+	}
+
+	@Test
+	public void authenticateWhenInvalidCodeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+		parameters.put(OAuth2ParameterNames.CODE, "invalid-code");
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CODE);
+			});
+	}
+
+	@Test
+	public void authenticateWhenMissingCodeChallengeThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+				assertThat(error.getDescription()).contains(PkceParameterNames.CODE_CHALLENGE);
+			});
+	}
+
+	@Test
+	public void authenticateWhenMissingCodeVerifierThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+				assertThat(error.getDescription()).contains(PkceParameterNames.CODE_VERIFIER);
+			});
+	}
+
+	@Test
+	public void authenticateWhenS256MethodAndInvalidCodeVerifierThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters("invalid-code-verifier");
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+				assertThat(error.getDescription()).contains(PkceParameterNames.CODE_VERIFIER);
+			});
+	}
+
+	@Test
+	public void authenticateWhenS256MethodAndValidCodeVerifierThenAuthenticated() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, parameters);
+
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isNull();
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void authenticateWhenUnsupportedCodeChallengeMethodThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		Map<String, Object> authorizationRequestAdditionalParameters = createPkceAuthorizationParametersS256();
+		// This should never happen: the Authorization endpoint should not allow it
+		authorizationRequestAdditionalParameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD,
+				"unsupported-challenge-method");
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, authorizationRequestAdditionalParameters)
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.NONE, null, parameters);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_GRANT);
+	}
+
+	private static Map<String, Object> createAuthorizationCodeTokenParameters() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceTokenParameters(String codeVerifier) {
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+		parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceAuthorizationParametersS256() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE);
+		return parameters;
+	}
+
+}

+ 515 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/authentication/X509ClientCertificateAuthenticationProviderTests.java

@@ -0,0 +1,515 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.authentication;
+
+import java.security.cert.X509Certificate;
+import java.security.interfaces.RSAPublicKey;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.UUID;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.KeyUse;
+import com.nimbusds.jose.jwk.RSAKey;
+import com.nimbusds.jose.util.Base64;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+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.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link X509ClientCertificateAuthenticationProvider}.
+ *
+ * @author Joe Grandja
+ */
+public class X509ClientCertificateAuthenticationProviderTests {
+
+	// See RFC 7636: Appendix B. Example for the S256 code_challenge_method
+	// https://tools.ietf.org/html/rfc7636#appendix-B
+	private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+	private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+	private static final String AUTHORIZATION_CODE = "code";
+
+	private static final OAuth2TokenType AUTHORIZATION_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.CODE);
+
+	private JWKSet selfSignedCertificateJwkSet;
+
+	private MockWebServer server;
+
+	private String clientJwkSetUrl;
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private X509ClientCertificateAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() throws Exception {
+		// @formatter:off
+		X509Certificate selfSignedCertificate = TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE[0];
+		RSAKey selfSignedRSAKey = new RSAKey.Builder((RSAPublicKey) selfSignedCertificate.getPublicKey())
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID(UUID.randomUUID().toString())
+				.x509CertChain(Collections.singletonList(Base64.encode(selfSignedCertificate.getEncoded())))
+				.build();
+		// @formatter:on
+		this.selfSignedCertificateJwkSet = new JWKSet(selfSignedRSAKey);
+		this.server = new MockWebServer();
+		this.server.start();
+		this.clientJwkSetUrl = this.server.url("/jwks").toString();
+		// @formatter:off
+		MockResponse response = new MockResponse()
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+				.setBody(this.selfSignedCertificateJwkSet.toString());
+		// @formatter:on
+		this.server.enqueue(response);
+
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new X509ClientCertificateAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService);
+	}
+
+	@AfterEach
+	public void tearDown() throws Exception {
+		this.server.shutdown();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(null, this.authorizationService))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new X509ClientCertificateAuthenticationProvider(this.registeredClientRepository, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void setCertificateVerifierWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setCertificateVerifier(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("certificateVerifier cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOAuth2ClientAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OAuth2ClientAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenInvalidClientIdThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId() + "-invalid", ClientAuthenticationMethod.TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
+			});
+	}
+
+	@Test
+	public void authenticateWhenUnsupportedClientAuthenticationMethodThenThrowOAuth2AuthenticationException() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("authentication_method");
+			});
+	}
+
+	@Test
+	public void authenticateWhenX509CertificateNotProvidedThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.TLS_CLIENT_AUTH, null, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("credentials");
+			});
+	}
+
+	@Test
+	public void authenticateWhenPKIX509CertificateInvalidSubjectDNThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN("CN=demo-client-sample-2,OU=Spring Samples,O=Spring,C=US")
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("x509_certificate_subject_dn");
+			});
+	}
+
+	@Test
+	public void authenticateWhenPKIX509CertificateValidThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
+
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.TLS_CLIENT_AUTH);
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateInvalidIssuerThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		// PKI Certificate will have different issuer
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("x509_certificate_issuer");
+			});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateMissingClientJwkSetUrlThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("client_jwk_set_url");
+			});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateInvalidClientJwkSetUrlThenThrowOAuth2AuthenticationException() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl("https://this is an invalid URL")
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains("jwk_set_uri");
+			});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseErrorStatusThenThrowOAuth2AuthenticationException() {
+		MockResponse jwkSetResponse = new MockResponse().setResponseCode(400);
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "jwk_set_response_error");
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidStatusThenThrowOAuth2AuthenticationException() {
+		MockResponse jwkSetResponse = new MockResponse().setResponseCode(204);
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "jwk_set_response_status");
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidContentThenThrowOAuth2AuthenticationException() {
+		MockResponse jwkSetResponse = new MockResponse().setResponseCode(200).setBody("invalid-content");
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "jwk_set_response_body");
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateJwkSetResponseNoMatchingKeysThenThrowOAuth2AuthenticationException()
+			throws Exception {
+		// @formatter:off
+		X509Certificate pkiCertificate = TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0];
+		RSAKey pkiRSAKey = new RSAKey.Builder((RSAPublicKey) pkiCertificate.getPublicKey())
+				.keyUse(KeyUse.SIGNATURE)
+				.keyID(UUID.randomUUID().toString())
+				.x509CertChain(Collections.singletonList(Base64.encode(pkiCertificate.getEncoded())))
+				.build();
+		// @formatter:on
+
+		// @formatter:off
+		MockResponse jwkSetResponse = new MockResponse()
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+				.setBody(new JWKSet(pkiRSAKey).toString());
+		// @formatter:on
+
+		authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+				jwkSetResponse, "x509_certificate");
+	}
+
+	private void authenticateWhenSelfSignedX509CertificateJwkSetResponseInvalidThenThrowOAuth2AuthenticationException(
+			final MockResponse jwkSetResponse, String expectedErrorDescription) {
+
+		// @formatter:off
+		final Dispatcher dispatcher = new Dispatcher() {
+			@Override
+			public MockResponse dispatch(RecordedRequest request) {
+				return jwkSetResponse;
+			}
+		};
+		this.server.setDispatcher(dispatcher);
+		// @formatter:on
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.satisfies((error) -> {
+				assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+				assertThat(error.getDescription()).contains(expectedErrorDescription);
+			});
+	}
+
+	@Test
+	public void authenticateWhenSelfSignedX509CertificateValidThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE, null);
+
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials())
+			.isEqualTo(TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH);
+	}
+
+	@Test
+	public void authenticateWhenPkceAndValidCodeVerifierThenAuthenticated() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.build();
+		// @formatter:on
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, createPkceAuthorizationParametersS256())
+			.build();
+		given(this.authorizationService.findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		Map<String, Object> parameters = createPkceTokenParameters(S256_CODE_VERIFIER);
+
+		OAuth2ClientAuthenticationToken authentication = new OAuth2ClientAuthenticationToken(
+				registeredClient.getClientId(), ClientAuthenticationMethod.TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE, parameters);
+
+		OAuth2ClientAuthenticationToken authenticationResult = (OAuth2ClientAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(AUTHORIZATION_CODE), eq(AUTHORIZATION_CODE_TOKEN_TYPE));
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+		assertThat(authenticationResult.getPrincipal().toString()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getCredentials()).isEqualTo(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
+		assertThat(authenticationResult.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(authenticationResult.getClientAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.TLS_CLIENT_AUTH);
+	}
+
+	private static Map<String, Object> createPkceAuthorizationParametersS256() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
+		parameters.put(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE);
+		return parameters;
+	}
+
+	private static Map<String, Object> createPkceTokenParameters(String codeVerifier) {
+		Map<String, Object> parameters = createAuthorizationCodeTokenParameters();
+		parameters.put(PkceParameterNames.CODE_VERIFIER, codeVerifier);
+		return parameters;
+	}
+
+	private static Map<String, Object> createAuthorizationCodeTokenParameters() {
+		Map<String, Object> parameters = new HashMap<>();
+		parameters.put(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.put(OAuth2ParameterNames.CODE, AUTHORIZATION_CODE);
+		return parameters;
+	}
+
+}

+ 204 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/InMemoryRegisteredClientRepositoryTests.java

@@ -0,0 +1,204 @@
+/*
+ * 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.client;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link InMemoryRegisteredClientRepository}.
+ *
+ * @author Anoop Garlapati
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ */
+public class InMemoryRegisteredClientRepositoryTests {
+
+	private RegisteredClient registration = TestRegisteredClients.registeredClient().build();
+
+	private InMemoryRegisteredClientRepository clients = new InMemoryRegisteredClientRepository(this.registration);
+
+	@Test
+	public void constructorVarargsRegisteredClientWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> {
+			RegisteredClient registration = null;
+			new InMemoryRegisteredClientRepository(registration);
+		}).withMessageContaining("registration cannot be null");
+	}
+
+	@Test
+	public void constructorListRegisteredClientWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> {
+			List<RegisteredClient> registrations = null;
+			new InMemoryRegisteredClientRepository(registrations);
+		}).withMessageContaining("registrations cannot be empty");
+	}
+
+	@Test
+	public void constructorListRegisteredClientWhenEmptyThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> {
+			List<RegisteredClient> registrations = Collections.emptyList();
+			new InMemoryRegisteredClientRepository(registrations);
+		}).withMessageContaining("registrations cannot be empty");
+	}
+
+	@Test
+	public void constructorListRegisteredClientWhenDuplicateIdThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> {
+			RegisteredClient anotherRegistrationWithSameId = TestRegisteredClients.registeredClient2()
+				.id(this.registration.getId())
+				.build();
+			List<RegisteredClient> registrations = Arrays.asList(this.registration, anotherRegistrationWithSameId);
+			new InMemoryRegisteredClientRepository(registrations);
+		}).withMessageStartingWith("Registered client must be unique. Found duplicate identifier:");
+	}
+
+	@Test
+	public void constructorListRegisteredClientWhenDuplicateClientIdThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> {
+			RegisteredClient anotherRegistrationWithSameClientId = TestRegisteredClients.registeredClient2()
+				.clientId(this.registration.getClientId())
+				.build();
+			List<RegisteredClient> registrations = Arrays.asList(this.registration,
+					anotherRegistrationWithSameClientId);
+			new InMemoryRegisteredClientRepository(registrations);
+		}).withMessageStartingWith("Registered client must be unique. Found duplicate client identifier:");
+	}
+
+	@Test
+	public void constructorListRegisteredClientWhenDuplicateClientSecretThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> {
+			RegisteredClient anotherRegistrationWithSameClientSecret = TestRegisteredClients.registeredClient2()
+				.clientSecret(this.registration.getClientSecret())
+				.build();
+			List<RegisteredClient> registrations = Arrays.asList(this.registration,
+					anotherRegistrationWithSameClientSecret);
+			new InMemoryRegisteredClientRepository(registrations);
+		}).withMessageStartingWith("Registered client must be unique. Found duplicate client secret for identifier:");
+	}
+
+	@Test
+	public void findByIdWhenFoundThenFound() {
+		String id = this.registration.getId();
+		assertThat(this.clients.findById(id)).isEqualTo(this.registration);
+	}
+
+	@Test
+	public void findByIdWhenNotFoundThenNull() {
+		String missingId = this.registration.getId() + "MISSING";
+		assertThat(this.clients.findById(missingId)).isNull();
+	}
+
+	@Test
+	public void findByIdWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.clients.findById(null))
+			.withMessageContaining("id cannot be empty");
+	}
+
+	@Test
+	public void findByClientIdWhenFoundThenFound() {
+		String clientId = this.registration.getClientId();
+		assertThat(this.clients.findByClientId(clientId)).isEqualTo(this.registration);
+	}
+
+	@Test
+	public void findByClientIdWhenNotFoundThenNull() {
+		String missingClientId = this.registration.getClientId() + "MISSING";
+		assertThat(this.clients.findByClientId(missingClientId)).isNull();
+	}
+
+	@Test
+	public void findByClientIdWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.clients.findByClientId(null))
+			.withMessageContaining("clientId cannot be empty");
+	}
+
+	@Test
+	public void saveWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.clients.save(null))
+			.withMessageContaining("registeredClient cannot be null");
+	}
+
+	@Test
+	public void saveWhenExistingIdThenUpdate() {
+		RegisteredClient registeredClient = createRegisteredClient(this.registration.getId(), "client-id-2",
+				"client-secret-2");
+		this.clients.save(registeredClient);
+		RegisteredClient savedClient = this.clients.findByClientId(registeredClient.getClientId());
+		assertThat(savedClient).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void saveWhenExistingClientIdThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient = createRegisteredClient("client-2", this.registration.getClientId(),
+				"client-secret-2");
+		assertThatIllegalArgumentException().isThrownBy(() -> this.clients.save(registeredClient))
+			.withMessage("Registered client must be unique. Found duplicate client identifier: "
+					+ registeredClient.getClientId());
+	}
+
+	@Test
+	public void saveWhenExistingClientSecretThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient = createRegisteredClient("client-2", "client-id-2",
+				this.registration.getClientSecret());
+		assertThatIllegalArgumentException().isThrownBy(() -> this.clients.save(registeredClient))
+			.withMessage("Registered client must be unique. Found duplicate client secret for identifier: "
+					+ registeredClient.getId());
+	}
+
+	@Test
+	public void saveWhenSavedAndFindByIdThenFound() {
+		RegisteredClient registeredClient = createRegisteredClient();
+		this.clients.save(registeredClient);
+		RegisteredClient savedClient = this.clients.findById(registeredClient.getId());
+		assertThat(savedClient).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void saveWhenSavedAndFindByClientIdThenFound() {
+		RegisteredClient registeredClient = createRegisteredClient();
+		this.clients.save(registeredClient);
+		RegisteredClient savedClient = this.clients.findByClientId(registeredClient.getClientId());
+		assertThat(savedClient).isEqualTo(registeredClient);
+	}
+
+	private static RegisteredClient createRegisteredClient() {
+		return createRegisteredClient("client-2", "client-id-2", "client-secret-2");
+	}
+
+	private static RegisteredClient createRegisteredClient(String id, String clientId, String clientSecret) {
+		// @formatter:off
+		return RegisteredClient.withId(id)
+				.clientId(clientId)
+				.clientSecret(clientSecret)
+				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+				.redirectUri("https://client.example.com")
+				.scope("scope1")
+				.build();
+		// @formatter:on
+	}
+
+}

+ 461 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/JdbcRegisteredClientRepositoryTests.java

@@ -0,0 +1,461 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.client;
+
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.sql.Timestamp;
+import java.time.Instant;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Function;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.Module;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.jdbc.core.ArgumentPreparedStatementSetter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.core.PreparedStatementSetter;
+import org.springframework.jdbc.core.RowMapper;
+import org.springframework.jdbc.core.SqlParameterValue;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.jackson2.SecurityJackson2Modules;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientRowMapper;
+import org.springframework.security.oauth2.server.authorization.jackson2.OAuth2AuthorizationServerJackson2Module;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link JdbcRegisteredClientRepository}.
+ *
+ * @author Rafal Lewczuk
+ * @author Steve Riesenberg
+ * @author Joe Grandja
+ * @author Ovidiu Popa
+ */
+public class JdbcRegisteredClientRepositoryTests {
+
+	private static final String OAUTH2_REGISTERED_CLIENT_SCHEMA_SQL_RESOURCE = "/org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql";
+
+	private static final String OAUTH2_CUSTOM_REGISTERED_CLIENT_SCHEMA_SQL_RESOURCE = "/org/springframework/security/oauth2/server/authorization/client/custom-oauth2-registered-client-schema.sql";
+
+	private EmbeddedDatabase db;
+
+	private JdbcOperations jdbcOperations;
+
+	private JdbcRegisteredClientRepository registeredClientRepository;
+
+	@BeforeEach
+	public void setUp() {
+		this.db = createDb(OAUTH2_REGISTERED_CLIENT_SCHEMA_SQL_RESOURCE);
+		this.jdbcOperations = new JdbcTemplate(this.db);
+		this.registeredClientRepository = new JdbcRegisteredClientRepository(this.jdbcOperations);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.db.shutdown();
+	}
+
+	@Test
+	public void constructorWhenJdbcOperationsIsNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> new JdbcRegisteredClientRepository(null))
+				.withMessage("jdbcOperations cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setRegisteredClientRowMapperWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.registeredClientRepository.setRegisteredClientRowMapper(null))
+				.withMessage("registeredClientRowMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void setRegisteredClientParametersMapperWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.registeredClientRepository.setRegisteredClientParametersMapper(null))
+				.withMessage("registeredClientParametersMapper cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void saveWhenRegisteredClientNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.registeredClientRepository.save(null))
+			.withMessageContaining("registeredClient cannot be null");
+	}
+
+	@Test
+	public void saveWhenRegisteredClientExistsThenUpdated() {
+		RegisteredClient originalRegisteredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(originalRegisteredClient);
+
+		RegisteredClient registeredClient = this.registeredClientRepository.findById(originalRegisteredClient.getId());
+		assertThat(registeredClient).isEqualTo(originalRegisteredClient);
+
+		RegisteredClient updatedRegisteredClient = RegisteredClient.from(originalRegisteredClient)
+			.clientId("test")
+			.clientIdIssuedAt(Instant.now())
+			.clientName("clientName")
+			.scope("scope2")
+			.build();
+
+		RegisteredClient expectedUpdatedRegisteredClient = RegisteredClient.from(originalRegisteredClient)
+			.clientName("clientName")
+			.scope("scope2")
+			.build();
+		this.registeredClientRepository.save(updatedRegisteredClient);
+
+		registeredClient = this.registeredClientRepository.findById(updatedRegisteredClient.getId());
+		assertThat(registeredClient).isEqualTo(expectedUpdatedRegisteredClient);
+		assertThat(registeredClient).isNotEqualTo(originalRegisteredClient);
+	}
+
+	@Test
+	public void saveWhenNewThenSaved() {
+		RegisteredClient expectedRegisteredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(
+					ClientSettings.builder().tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256).build())
+			.build();
+		this.registeredClientRepository.save(expectedRegisteredClient);
+		RegisteredClient registeredClient = this.registeredClientRepository.findById(expectedRegisteredClient.getId());
+		assertThat(registeredClient).isEqualTo(expectedRegisteredClient);
+	}
+
+	@Test
+	public void saveWhenClientSecretNullThenSaved() {
+		RegisteredClient expectedRegisteredClient = TestRegisteredClients.registeredClient().clientSecret(null).build();
+		this.registeredClientRepository.save(expectedRegisteredClient);
+		RegisteredClient registeredClient = this.registeredClientRepository.findById(expectedRegisteredClient.getId());
+		assertThat(registeredClient).isEqualTo(expectedRegisteredClient);
+	}
+
+	// gh-1641
+	@Test
+	public void saveWhenMultipleWithClientSecretEmptyThenSaved() {
+		RegisteredClient registeredClient1 = TestRegisteredClients.registeredClient()
+			.id("registration-1")
+			.clientId("client-1")
+			.clientSecret("")
+			.build();
+		this.registeredClientRepository.save(registeredClient1);
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient()
+			.id("registration-2")
+			.clientId("client-2")
+			.clientSecret("")
+			.build();
+		this.registeredClientRepository.save(registeredClient2);
+	}
+
+	@Test
+	public void saveWhenExistingClientIdThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient1 = TestRegisteredClients.registeredClient()
+			.id("registration-1")
+			.clientId("client-1")
+			.build();
+		this.registeredClientRepository.save(registeredClient1);
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient()
+			.id("registration-2")
+			.clientId("client-1")
+			.build();
+		assertThatIllegalArgumentException().isThrownBy(() -> this.registeredClientRepository.save(registeredClient2))
+			.withMessage("Registered client must be unique. Found duplicate client identifier: "
+					+ registeredClient2.getClientId());
+	}
+
+	@Test
+	public void saveWhenExistingClientSecretThenThrowIllegalArgumentException() {
+		RegisteredClient registeredClient1 = TestRegisteredClients.registeredClient()
+			.id("registration-1")
+			.clientId("client-1")
+			.clientSecret("secret")
+			.build();
+		this.registeredClientRepository.save(registeredClient1);
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient()
+			.id("registration-2")
+			.clientId("client-2")
+			.clientSecret("secret")
+			.build();
+		assertThatIllegalArgumentException().isThrownBy(() -> this.registeredClientRepository.save(registeredClient2))
+			.withMessage("Registered client must be unique. Found duplicate client secret for identifier: "
+					+ registeredClient2.getId());
+	}
+
+	@Test
+	public void saveLoadRegisteredClientWhenCustomStrategiesSetThenCalled() throws Exception {
+		RowMapper<RegisteredClient> registeredClientRowMapper = spy(new RegisteredClientRowMapper());
+		this.registeredClientRepository.setRegisteredClientRowMapper(registeredClientRowMapper);
+		RegisteredClientParametersMapper clientParametersMapper = new RegisteredClientParametersMapper();
+		Function<RegisteredClient, List<SqlParameterValue>> registeredClientParametersMapper = spy(
+				clientParametersMapper);
+		this.registeredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+		RegisteredClient result = this.registeredClientRepository.findById(registeredClient.getId());
+		assertThat(result).isEqualTo(registeredClient);
+		verify(registeredClientRowMapper).mapRow(any(), anyInt());
+		verify(registeredClientParametersMapper).apply(any());
+	}
+
+	@Test
+	public void findByIdWhenIdNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.registeredClientRepository.findById(null))
+				.withMessage("id cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByIdWhenExistsThenFound() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+		RegisteredClient result = this.registeredClientRepository.findById(registeredClient.getId());
+		assertThat(result).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void findByIdWhenNotExistsThenNotFound() {
+		RegisteredClient result = this.registeredClientRepository.findById("not-exists");
+		assertThat(result).isNull();
+	}
+
+	@Test
+	public void findByClientIdWhenClientIdNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatIllegalArgumentException()
+				.isThrownBy(() -> this.registeredClientRepository.findByClientId(null))
+				.withMessage("clientId cannot be empty");
+		// @formatter:on
+	}
+
+	@Test
+	public void findByClientIdWhenExistsThenFound() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+		RegisteredClient result = this.registeredClientRepository.findByClientId(registeredClient.getClientId());
+		assertThat(result).isEqualTo(registeredClient);
+	}
+
+	@Test
+	public void findByClientIdWhenNotExistsThenNotFound() {
+		RegisteredClient result = this.registeredClientRepository.findByClientId("not-exists");
+		assertThat(result).isNull();
+	}
+
+	@Test
+	public void tableDefinitionWhenCustomThenAbleToOverride() {
+		EmbeddedDatabase db = createDb(OAUTH2_CUSTOM_REGISTERED_CLIENT_SCHEMA_SQL_RESOURCE);
+		CustomJdbcRegisteredClientRepository registeredClientRepository = new CustomJdbcRegisteredClientRepository(
+				new JdbcTemplate(db));
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		registeredClientRepository.save(registeredClient);
+		RegisteredClient foundRegisteredClient1 = registeredClientRepository.findById(registeredClient.getId());
+		assertThat(foundRegisteredClient1).isEqualTo(registeredClient);
+		RegisteredClient foundRegisteredClient2 = registeredClientRepository
+			.findByClientId(registeredClient.getClientId());
+		assertThat(foundRegisteredClient2).isEqualTo(registeredClient);
+		db.shutdown();
+	}
+
+	private static EmbeddedDatabase createDb(String schema) {
+		// @formatter:off
+		return new EmbeddedDatabaseBuilder()
+				.generateUniqueName(true)
+				.setType(EmbeddedDatabaseType.HSQL)
+				.setScriptEncoding("UTF-8")
+				.addScript(schema)
+				.build();
+		// @formatter:on
+	}
+
+	private static final class CustomJdbcRegisteredClientRepository extends JdbcRegisteredClientRepository {
+
+		// @formatter:off
+		private static final String COLUMN_NAMES = "id, "
+				+ "clientId, "
+				+ "clientIdIssuedAt, "
+				+ "clientSecret, "
+				+ "clientSecretExpiresAt, "
+				+ "clientName, "
+				+ "clientAuthenticationMethods, "
+				+ "authorizationGrantTypes, "
+				+ "redirectUris, "
+				+ "postLogoutRedirectUris, "
+				+ "scopes, "
+				+ "clientSettings,"
+				+ "tokenSettings";
+		// @formatter:on
+
+		private static final String TABLE_NAME = "oauth2RegisteredClient";
+
+		private static final String LOAD_REGISTERED_CLIENT_SQL = "SELECT " + COLUMN_NAMES + " FROM " + TABLE_NAME
+				+ " WHERE ";
+
+		// @formatter:off
+		private static final String INSERT_REGISTERED_CLIENT_SQL = "INSERT INTO " + TABLE_NAME
+				+ " (" + COLUMN_NAMES + ") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
+		// @formatter:on
+
+		private CustomJdbcRegisteredClientRepository(JdbcOperations jdbcOperations) {
+			super(jdbcOperations);
+			setRegisteredClientRowMapper(new CustomRegisteredClientRowMapper());
+		}
+
+		@Override
+		public void save(RegisteredClient registeredClient) {
+			List<SqlParameterValue> parameters = getRegisteredClientParametersMapper().apply(registeredClient);
+			PreparedStatementSetter pss = new ArgumentPreparedStatementSetter(parameters.toArray());
+			getJdbcOperations().update(INSERT_REGISTERED_CLIENT_SQL, pss);
+		}
+
+		@Override
+		public RegisteredClient findById(String id) {
+			return findBy("id = ?", id);
+		}
+
+		@Override
+		public RegisteredClient findByClientId(String clientId) {
+			return findBy("clientId = ?", clientId);
+		}
+
+		private RegisteredClient findBy(String filter, Object... args) {
+			List<RegisteredClient> result = getJdbcOperations().query(LOAD_REGISTERED_CLIENT_SQL + filter,
+					getRegisteredClientRowMapper(), args);
+			return !result.isEmpty() ? result.get(0) : null;
+		}
+
+		private static final class CustomRegisteredClientRowMapper implements RowMapper<RegisteredClient> {
+
+			private final ObjectMapper objectMapper = new ObjectMapper();
+
+			private CustomRegisteredClientRowMapper() {
+				ClassLoader classLoader = CustomJdbcRegisteredClientRepository.class.getClassLoader();
+				List<Module> securityModules = SecurityJackson2Modules.getModules(classLoader);
+				this.objectMapper.registerModules(securityModules);
+				this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
+			}
+
+			@Override
+			public RegisteredClient mapRow(ResultSet rs, int rowNum) throws SQLException {
+				Timestamp clientIdIssuedAt = rs.getTimestamp("clientIdIssuedAt");
+				Timestamp clientSecretExpiresAt = rs.getTimestamp("clientSecretExpiresAt");
+				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
+				RegisteredClient.Builder builder = RegisteredClient.withId(rs.getString("id"))
+						.clientId(rs.getString("clientId"))
+						.clientIdIssuedAt((clientIdIssuedAt != null) ? clientIdIssuedAt.toInstant() : null)
+						.clientSecret(rs.getString("clientSecret"))
+						.clientSecretExpiresAt((clientSecretExpiresAt != null) ? clientSecretExpiresAt.toInstant() : null)
+						.clientName(rs.getString("clientName"))
+						.clientAuthenticationMethods((authenticationMethods) ->
+								clientAuthenticationMethods.forEach((authenticationMethod) ->
+										authenticationMethods.add(resolveClientAuthenticationMethod(authenticationMethod))))
+						.authorizationGrantTypes((grantTypes) ->
+								authorizationGrantTypes.forEach((grantType) ->
+										grantTypes.add(resolveAuthorizationGrantType(grantType))))
+						.redirectUris((uris) -> uris.addAll(redirectUris))
+						.postLogoutRedirectUris((uris) -> uris.addAll(postLogoutRedirectUris))
+						.scopes((scopes) -> scopes.addAll(clientScopes));
+				// @formatter:on
+
+				Map<String, Object> clientSettingsMap = parseMap(rs.getString("clientSettings"));
+				builder.clientSettings(ClientSettings.withSettings(clientSettingsMap).build());
+
+				Map<String, Object> tokenSettingsMap = parseMap(rs.getString("tokenSettings"));
+				builder.tokenSettings(TokenSettings.withSettings(tokenSettingsMap).build());
+
+				return builder.build();
+			}
+
+			private Map<String, Object> parseMap(String data) {
+				try {
+					return this.objectMapper.readValue(data, new TypeReference<>() {
+					});
+				}
+				catch (Exception ex) {
+					throw new IllegalArgumentException(ex.getMessage(), ex);
+				}
+			}
+
+			private static AuthorizationGrantType resolveAuthorizationGrantType(String authorizationGrantType) {
+				if (AuthorizationGrantType.AUTHORIZATION_CODE.getValue().equals(authorizationGrantType)) {
+					return AuthorizationGrantType.AUTHORIZATION_CODE;
+				}
+				else if (AuthorizationGrantType.CLIENT_CREDENTIALS.getValue().equals(authorizationGrantType)) {
+					return AuthorizationGrantType.CLIENT_CREDENTIALS;
+				}
+				else if (AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(authorizationGrantType)) {
+					return AuthorizationGrantType.REFRESH_TOKEN;
+				}
+				// Custom authorization grant type
+				return new AuthorizationGrantType(authorizationGrantType);
+			}
+
+			private static ClientAuthenticationMethod resolveClientAuthenticationMethod(
+					String clientAuthenticationMethod) {
+				if (ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue().equals(clientAuthenticationMethod)) {
+					return ClientAuthenticationMethod.CLIENT_SECRET_BASIC;
+				}
+				else if (ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue().equals(clientAuthenticationMethod)) {
+					return ClientAuthenticationMethod.CLIENT_SECRET_POST;
+				}
+				else if (ClientAuthenticationMethod.NONE.getValue().equals(clientAuthenticationMethod)) {
+					return ClientAuthenticationMethod.NONE;
+				}
+				// Custom client authentication method
+				return new ClientAuthenticationMethod(clientAuthenticationMethod);
+			}
+
+		}
+
+	}
+
+}

+ 440 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/client/RegisteredClientTests.java

@@ -0,0 +1,440 @@
+/*
+ * 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.client;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.Set;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link RegisteredClient}.
+ *
+ * @author Anoop Garlapati
+ */
+public class RegisteredClientTests {
+
+	private static final String ID = "registration-1";
+
+	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 = Collections
+		.singleton(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
+
+	@Test
+	public void buildWhenAuthorizationGrantTypesNotSetThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenAllAttributesProvidedThenAllAttributesAreSet() {
+		Instant clientIdIssuedAt = Instant.now();
+		Instant clientSecretExpiresAt = clientIdIssuedAt.plus(30, ChronoUnit.DAYS);
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientIdIssuedAt(clientIdIssuedAt)
+			.clientSecret(CLIENT_SECRET)
+			.clientSecretExpiresAt(clientSecretExpiresAt)
+			.clientName("client-name")
+			.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();
+
+		assertThat(registration.getId()).isEqualTo(ID);
+		assertThat(registration.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(registration.getClientIdIssuedAt()).isEqualTo(clientIdIssuedAt);
+		assertThat(registration.getClientSecret()).isEqualTo(CLIENT_SECRET);
+		assertThat(registration.getClientSecretExpiresAt()).isEqualTo(clientSecretExpiresAt);
+		assertThat(registration.getClientName()).isEqualTo("client-name");
+		assertThat(registration.getAuthorizationGrantTypes())
+			.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);
+	}
+
+	@Test
+	public void buildWhenIdIsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(null)).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenClientIdIsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(null)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenRedirectUrisNotProvidedThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenRedirectUrisConsumerClearsSetThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUri("https://example.com")
+			.redirectUris(Set::clear)
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenClientAuthenticationMethodNotProvidedThenDefaultToBasic() {
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getClientAuthenticationMethods())
+			.isEqualTo(Collections.singleton(ClientAuthenticationMethod.CLIENT_SECRET_BASIC));
+	}
+
+	@Test
+	public void buildWhenScopeIsEmptyThenScopeNotRequired() {
+		RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.build();
+	}
+
+	@Test
+	public void buildWhenScopeConsumerIsProvidedThenConsumerAccepted() {
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getScopes()).isEqualTo(SCOPES);
+	}
+
+	@Test
+	public void buildWhenScopeContainsSpaceThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scope("openid profile")
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenScopeContainsInvalidCharacterThenThrowIllegalArgumentException() {
+		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))
+			.scope("an\"invalid\"scope")
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenRedirectUriInvalidThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUri("invalid URI")
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenRedirectUriContainsFragmentThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUri("https://example.com/page#fragment")
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build()).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)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getAuthorizationGrantTypes()).containsExactlyInAnyOrder(
+				AuthorizationGrantType.AUTHORIZATION_CODE, AuthorizationGrantType.CLIENT_CREDENTIALS);
+	}
+
+	@Test
+	public void buildWhenAuthorizationGrantTypesConsumerIsProvidedThenConsumerAccepted() {
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantTypes((authorizationGrantTypes) -> {
+				authorizationGrantTypes.add(AuthorizationGrantType.AUTHORIZATION_CODE);
+				authorizationGrantTypes.add(AuthorizationGrantType.CLIENT_CREDENTIALS);
+			})
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getAuthorizationGrantTypes()).containsExactlyInAnyOrder(
+				AuthorizationGrantType.AUTHORIZATION_CODE, AuthorizationGrantType.CLIENT_CREDENTIALS);
+	}
+
+	@Test
+	public void buildWhenAuthorizationGrantTypesConsumerClearsSetThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.authorizationGrantTypes(Set::clear)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build()).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenTwoClientAuthenticationMethodsAreProvidedThenBothAreRegistered() {
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getClientAuthenticationMethods()).containsExactlyInAnyOrder(
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, ClientAuthenticationMethod.CLIENT_SECRET_POST);
+	}
+
+	@Test
+	public void buildWhenClientAuthenticationMethodsConsumerIsProvidedThenConsumerAccepted() {
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethods((clientAuthenticationMethods) -> {
+				clientAuthenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_BASIC);
+				clientAuthenticationMethods.add(ClientAuthenticationMethod.CLIENT_SECRET_POST);
+			})
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getClientAuthenticationMethods()).containsExactlyInAnyOrder(
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, ClientAuthenticationMethod.CLIENT_SECRET_POST);
+	}
+
+	@Test
+	public void buildWhenOverrideIdThenOverridden() {
+		String overriddenId = "override";
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.id(overriddenId)
+			.clientId(CLIENT_ID)
+			.clientSecret(CLIENT_SECRET)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getId()).isEqualTo(overriddenId);
+	}
+
+	@Test
+	public void buildWhenRegisteredClientProvidedThenMakesACopy() {
+		RegisteredClient registration = TestRegisteredClients.registeredClient().build();
+		RegisteredClient updated = RegisteredClient.from(registration).build();
+
+		assertThat(registration.getId()).isEqualTo(updated.getId());
+		assertThat(registration.getClientId()).isEqualTo(updated.getClientId());
+		assertThat(registration.getClientIdIssuedAt()).isEqualTo(updated.getClientIdIssuedAt());
+		assertThat(registration.getClientSecret()).isEqualTo(updated.getClientSecret());
+		assertThat(registration.getClientSecretExpiresAt()).isEqualTo(updated.getClientSecretExpiresAt());
+		assertThat(registration.getClientName()).isEqualTo(updated.getClientName());
+		assertThat(registration.getClientAuthenticationMethods()).isEqualTo(updated.getClientAuthenticationMethods());
+		assertThat(registration.getClientAuthenticationMethods()).isNotSameAs(updated.getClientAuthenticationMethods());
+		assertThat(registration.getAuthorizationGrantTypes()).isEqualTo(updated.getAuthorizationGrantTypes());
+		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());
+		assertThat(registration.getClientSettings()).isNotSameAs(updated.getClientSettings());
+		assertThat(registration.getTokenSettings()).isEqualTo(updated.getTokenSettings());
+		assertThat(registration.getTokenSettings()).isNotSameAs(updated.getTokenSettings());
+	}
+
+	@Test
+	public void buildWhenRegisteredClientValuesOverriddenThenPropagated() {
+		RegisteredClient registration = TestRegisteredClients.registeredClient().build();
+		String newName = "client-name";
+		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)
+			.scopes((scopes) -> {
+				scopes.clear();
+				scopes.add(newScope);
+			})
+			.redirectUris((redirectUris) -> {
+				redirectUris.clear();
+				redirectUris.add(newRedirectUri);
+			})
+			.postLogoutRedirectUris((postLogoutRedirectUris) -> {
+				postLogoutRedirectUris.clear();
+				postLogoutRedirectUris.add(newPostLogoutRedirectUri);
+			})
+			.build();
+
+		assertThat(registration.getClientName()).isNotEqualTo(newName);
+		assertThat(updated.getClientName()).isEqualTo(newName);
+		assertThat(registration.getClientSecret()).isNotEqualTo(newSecret);
+		assertThat(updated.getClientSecret()).isEqualTo(newSecret);
+		assertThat(registration.getScopes()).doesNotContain(newScope);
+		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
+	public void buildWhenPublicClientTypeThenDefaultSettings() {
+		Instant clientIdIssuedAt = Instant.now();
+		RegisteredClient registration = RegisteredClient.withId(ID)
+			.clientId(CLIENT_ID)
+			.clientIdIssuedAt(clientIdIssuedAt)
+			.clientName("client-name")
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
+			.redirectUris((redirectUris) -> redirectUris.addAll(REDIRECT_URIS))
+			.scopes((scopes) -> scopes.addAll(SCOPES))
+			.build();
+
+		assertThat(registration.getId()).isEqualTo(ID);
+		assertThat(registration.getClientId()).isEqualTo(CLIENT_ID);
+		assertThat(registration.getClientIdIssuedAt()).isEqualTo(clientIdIssuedAt);
+		assertThat(registration.getClientName()).isEqualTo("client-name");
+		assertThat(registration.getAuthorizationGrantTypes())
+			.isEqualTo(Collections.singleton(AuthorizationGrantType.AUTHORIZATION_CODE));
+		assertThat(registration.getClientAuthenticationMethods())
+			.isEqualTo(Collections.singleton(ClientAuthenticationMethod.NONE));
+		assertThat(registration.getRedirectUris()).isEqualTo(REDIRECT_URIS);
+		assertThat(registration.getScopes()).isEqualTo(SCOPES);
+		assertThat(registration.getClientSettings().isRequireProofKey()).isTrue();
+		assertThat(registration.getClientSettings().isRequireAuthorizationConsent()).isTrue();
+	}
+
+}

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

@@ -0,0 +1,75 @@
+/*
+ * 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.client;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+
+/**
+ * @author Anoop Garlapati
+ */
+public final class TestRegisteredClients {
+
+	private TestRegisteredClients() {
+	}
+
+	public static RegisteredClient.Builder registeredClient() {
+		return RegisteredClient.withId("registration-1")
+			.clientId("client-1")
+			.clientIdIssuedAt(Instant.now().truncatedTo(ChronoUnit.SECONDS))
+			.clientSecret("secret-1")
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
+			.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");
+	}
+
+	public static RegisteredClient.Builder registeredClient2() {
+		return RegisteredClient.withId("registration-2")
+			.clientId("client-2")
+			.clientIdIssuedAt(Instant.now().truncatedTo(ChronoUnit.SECONDS))
+			.clientSecret("secret-2")
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+			.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");
+	}
+
+	public static RegisteredClient.Builder registeredPublicClient() {
+		return RegisteredClient.withId("registration-3")
+			.clientId("client-3")
+			.clientIdIssuedAt(Instant.now().truncatedTo(ChronoUnit.SECONDS))
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
+			.redirectUri("https://example.com")
+			.scope("scope1")
+			.clientSettings(ClientSettings.builder().requireProofKey(true).build());
+	}
+
+}

+ 45 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configuration/OAuth2AuthorizationServerConfigurationTests.java

@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020-2022 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.configuration;
+
+import java.lang.reflect.Method;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.Ordered;
+import org.springframework.core.annotation.OrderUtils;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.util.ClassUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OAuth2AuthorizationServerConfiguration}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2AuthorizationServerConfigurationTests {
+
+	@Test
+	public void assertOrderHighestPrecedence() {
+		Method authorizationServerSecurityFilterChainMethod = ClassUtils.getMethod(
+				OAuth2AuthorizationServerConfiguration.class, "authorizationServerSecurityFilterChain",
+				HttpSecurity.class);
+		Integer order = OrderUtils.getOrder(authorizationServerSecurityFilterChainMethod);
+		assertThat(order).isEqualTo(Ordered.HIGHEST_PRECEDENCE);
+	}
+
+}

+ 112 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configuration/RegisterMissingBeanPostProcessorTests.java

@@ -0,0 +1,112 @@
+/*
+ * Copyright 2020-2022 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.configuration;
+
+import java.util.function.Supplier;
+
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.config.BeanDefinition;
+import org.springframework.beans.factory.support.BeanDefinitionRegistry;
+import org.springframework.beans.factory.support.DefaultListableBeanFactory;
+import org.springframework.beans.factory.support.RootBeanDefinition;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.endsWith;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link RegisterMissingBeanPostProcessor}.
+ *
+ * @author Steve Riesenberg
+ */
+public class RegisterMissingBeanPostProcessorTests {
+
+	private final RegisterMissingBeanPostProcessor postProcessor = new RegisterMissingBeanPostProcessor();
+
+	@Test
+	public void postProcessBeanDefinitionRegistryWhenClassAddedThenRegisteredWithClass() {
+		this.postProcessor.addBeanDefinition(SimpleBean.class, null);
+		this.postProcessor.setBeanFactory(new DefaultListableBeanFactory());
+
+		BeanDefinitionRegistry beanDefinitionRegistry = mock(BeanDefinitionRegistry.class);
+		this.postProcessor.postProcessBeanDefinitionRegistry(beanDefinitionRegistry);
+
+		ArgumentCaptor<BeanDefinition> beanDefinitionCaptor = ArgumentCaptor.forClass(BeanDefinition.class);
+		verify(beanDefinitionRegistry).registerBeanDefinition(endsWith("SimpleBean"), beanDefinitionCaptor.capture());
+
+		RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionCaptor.getValue();
+		assertThat(beanDefinition.getBeanClass()).isEqualTo(SimpleBean.class);
+		assertThat(beanDefinition.getInstanceSupplier()).isNull();
+	}
+
+	@Test
+	public void postProcessBeanDefinitionRegistryWhenSupplierAddedThenRegisteredWithSupplier() {
+		Supplier<SimpleBean> beanSupplier = () -> new SimpleBean("string");
+		this.postProcessor.addBeanDefinition(SimpleBean.class, beanSupplier);
+		this.postProcessor.setBeanFactory(new DefaultListableBeanFactory());
+
+		BeanDefinitionRegistry beanDefinitionRegistry = mock(BeanDefinitionRegistry.class);
+		this.postProcessor.postProcessBeanDefinitionRegistry(beanDefinitionRegistry);
+
+		ArgumentCaptor<BeanDefinition> beanDefinitionCaptor = ArgumentCaptor.forClass(BeanDefinition.class);
+		verify(beanDefinitionRegistry).registerBeanDefinition(endsWith("SimpleBean"), beanDefinitionCaptor.capture());
+
+		RootBeanDefinition beanDefinition = (RootBeanDefinition) beanDefinitionCaptor.getValue();
+		assertThat(beanDefinition.getBeanClass()).isEqualTo(SimpleBean.class);
+		assertThat(beanDefinition.getInstanceSupplier()).isEqualTo(beanSupplier);
+	}
+
+	@Test
+	public void postProcessBeanDefinitionRegistryWhenNoBeanDefinitionsAddedThenNoneRegistered() {
+		this.postProcessor.setBeanFactory(new DefaultListableBeanFactory());
+
+		BeanDefinitionRegistry beanDefinitionRegistry = mock(BeanDefinitionRegistry.class);
+		this.postProcessor.postProcessBeanDefinitionRegistry(beanDefinitionRegistry);
+		verifyNoInteractions(beanDefinitionRegistry);
+	}
+
+	@Test
+	public void postProcessBeanDefinitionRegistryWhenBeanDefinitionAlreadyExistsThenNoneRegistered() {
+		this.postProcessor.addBeanDefinition(SimpleBean.class, null);
+		DefaultListableBeanFactory beanFactory = new DefaultListableBeanFactory();
+		beanFactory.registerBeanDefinition("simpleBean", new RootBeanDefinition(SimpleBean.class));
+		this.postProcessor.setBeanFactory(beanFactory);
+
+		BeanDefinitionRegistry beanDefinitionRegistry = mock(BeanDefinitionRegistry.class);
+		this.postProcessor.postProcessBeanDefinitionRegistry(beanDefinitionRegistry);
+		verifyNoInteractions(beanDefinitionRegistry);
+	}
+
+	private static final class SimpleBean {
+
+		private final String field;
+
+		private SimpleBean(String field) {
+			this.field = field;
+		}
+
+		private String getField() {
+			return this.field;
+		}
+
+	}
+
+}

+ 137 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/AuthorizationServerContextFilterTests.java

@@ -0,0 +1,137 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.atomic.AtomicReference;
+
+import jakarta.servlet.FilterChain;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.oauth2.server.authorization.context.AuthorizationServerContextHolder;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link AuthorizationServerContextFilter}.
+ *
+ * @author Joe Grandja
+ */
+class AuthorizationServerContextFilterTests {
+
+	private static final String SCHEME = "https";
+
+	private static final String HOST = "example.com";
+
+	private static final int PORT = 8443;
+
+	private static final String DEFAULT_ISSUER = SCHEME + "://" + HOST + ":" + PORT;
+
+	private AuthorizationServerContextFilter filter;
+
+	@Test
+	void doFilterWhenDefaultEndpointsThenIssuerResolved() throws Exception {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		this.filter = new AuthorizationServerContextFilter(authorizationServerSettings);
+
+		String issuerPath = "/issuer1";
+		String issuerWithPath = DEFAULT_ISSUER.concat(issuerPath);
+		Set<String> endpointUris = getEndpointUris(authorizationServerSettings);
+
+		for (String endpointUri : endpointUris) {
+			assertResolvedIssuer(issuerPath.concat(endpointUri), issuerWithPath);
+		}
+	}
+
+	@Test
+	void doFilterWhenCustomEndpointsThenIssuerResolved() throws Exception {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.authorizationEndpoint("/oauth2/v1/authorize")
+			.deviceAuthorizationEndpoint("/oauth2/v1/device_authorization")
+			.deviceVerificationEndpoint("/oauth2/v1/device_verification")
+			.tokenEndpoint("/oauth2/v1/token")
+			.jwkSetEndpoint("/oauth2/v1/jwks")
+			.tokenRevocationEndpoint("/oauth2/v1/revoke")
+			.tokenIntrospectionEndpoint("/oauth2/v1/introspect")
+			.oidcClientRegistrationEndpoint("/connect/v1/register")
+			.oidcUserInfoEndpoint("/v1/userinfo")
+			.oidcLogoutEndpoint("/connect/v1/logout")
+			.build();
+		this.filter = new AuthorizationServerContextFilter(authorizationServerSettings);
+
+		String issuerPath = "/issuer2";
+		String issuerWithPath = DEFAULT_ISSUER.concat(issuerPath);
+		Set<String> endpointUris = getEndpointUris(authorizationServerSettings);
+
+		for (String endpointUri : endpointUris) {
+			assertResolvedIssuer(issuerPath.concat(endpointUri), issuerWithPath);
+		}
+	}
+
+	@Test
+	void doFilterWhenIssuerHasMultiplePathsThenIssuerResolved() throws Exception {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+		this.filter = new AuthorizationServerContextFilter(authorizationServerSettings);
+
+		String issuerPath = "/path1/path2/issuer3";
+		String issuerWithPath = DEFAULT_ISSUER.concat(issuerPath);
+		Set<String> endpointUris = getEndpointUris(authorizationServerSettings);
+
+		for (String endpointUri : endpointUris) {
+			assertResolvedIssuer(issuerPath.concat(endpointUri), issuerWithPath);
+		}
+	}
+
+	private void assertResolvedIssuer(String requestUri, String expectedIssuer) throws Exception {
+		MockHttpServletRequest request = createRequest(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+
+		AtomicReference<String> resolvedIssuer = new AtomicReference<>();
+		FilterChain filterChain = (req, resp) -> resolvedIssuer
+			.set(AuthorizationServerContextHolder.getContext().getIssuer());
+
+		this.filter.doFilter(request, response, filterChain);
+
+		assertThat(resolvedIssuer.get()).isEqualTo(expectedIssuer);
+	}
+
+	private static Set<String> getEndpointUris(AuthorizationServerSettings authorizationServerSettings) {
+		Set<String> endpointUris = new HashSet<>();
+		endpointUris.add("/.well-known/oauth-authorization-server");
+		endpointUris.add("/.well-known/openid-configuration");
+		for (Map.Entry<String, Object> setting : authorizationServerSettings.getSettings().entrySet()) {
+			if (setting.getKey().endsWith("-endpoint")) {
+				endpointUris.add((String) setting.getValue());
+			}
+		}
+		return endpointUris;
+	}
+
+	private static MockHttpServletRequest createRequest(String requestUri) {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		request.setRequestURI(requestUri);
+		request.setScheme(SCHEME);
+		request.setServerName(HOST);
+		request.setServerPort(PORT);
+		return request;
+	}
+
+}

+ 242 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/DefaultOAuth2TokenCustomizersTests.java

@@ -0,0 +1,242 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.JwtClaimNames;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeActor;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeCompositeAuthenticationToken;
+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.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.entry;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link DefaultOAuth2TokenCustomizers}.
+ *
+ * @author Steve Riesenberg
+ * @author Joe Grandja
+ */
+class DefaultOAuth2TokenCustomizersTests {
+
+	private static final String ISSUER_1 = "issuer-1";
+
+	private static final String ISSUER_2 = "issuer-2";
+
+	private JwsHeader.Builder jwsHeaderBuilder;
+
+	private JwtClaimsSet.Builder jwtClaimsBuilder;
+
+	@BeforeEach
+	void setUp() {
+		this.jwsHeaderBuilder = JwsHeader.with(SignatureAlgorithm.RS256);
+		this.jwtClaimsBuilder = JwtClaimsSet.builder().issuer(ISSUER_1);
+	}
+
+	@Test
+	void customizeWhenTokenTypeIsRefreshTokenThenNoClaimsAdded() {
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.REFRESH_TOKEN)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		assertThat(jwtClaimsSet.getClaims()).containsOnly(entry(JwtClaimNames.ISS, ISSUER_1));
+	}
+
+	@Test
+	void customizeWhenAuthorizationGrantIsNullThenNoClaimsAdded() {
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		assertThat(jwtClaimsSet.getClaims()).containsOnly(entry(JwtClaimNames.ISS, ISSUER_1));
+	}
+
+	@Test
+	void customizeWhenTokenExchangeGrantAndResourcesThenNoClaimsAdded() {
+		OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = mock(
+				OAuth2TokenExchangeAuthenticationToken.class);
+		given(tokenExchangeAuthentication.getResources()).willReturn(Set.of("resource1", "resource2"));
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.authorizationGrant(tokenExchangeAuthentication)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		// We do not populate claims (e.g. `aud`) based on the resource parameter
+		assertThat(jwtClaimsSet.getClaims()).containsOnly(entry(JwtClaimNames.ISS, ISSUER_1));
+	}
+
+	@Test
+	void customizeWhenTokenExchangeGrantAndAudiencesThenNoClaimsAdded() {
+		OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = mock(
+				OAuth2TokenExchangeAuthenticationToken.class);
+		given(tokenExchangeAuthentication.getAudiences()).willReturn(Set.of("audience1", "audience2"));
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.authorizationGrant(tokenExchangeAuthentication)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		// NOTE: We do not populate claims (e.g. `aud`) based on the audience parameter
+		assertThat(jwtClaimsSet.getClaims()).containsOnly(entry(JwtClaimNames.ISS, ISSUER_1));
+	}
+
+	@Test
+	void customizeWhenTokenExchangeGrantAndDelegationThenActClaimAdded() {
+		OAuth2TokenExchangeAuthenticationToken tokenExchangeAuthentication = mock(
+				OAuth2TokenExchangeAuthenticationToken.class);
+		given(tokenExchangeAuthentication.getAudiences()).willReturn(Collections.emptySet());
+
+		Authentication subject = new TestingAuthenticationToken("subject", null);
+		OAuth2TokenExchangeActor actor1 = new OAuth2TokenExchangeActor(
+				Map.of(JwtClaimNames.ISS, ISSUER_1, JwtClaimNames.SUB, "actor1"));
+		OAuth2TokenExchangeActor actor2 = new OAuth2TokenExchangeActor(
+				Map.of(JwtClaimNames.ISS, ISSUER_2, JwtClaimNames.SUB, "actor2"));
+		OAuth2TokenExchangeCompositeAuthenticationToken principal = new OAuth2TokenExchangeCompositeAuthenticationToken(
+				subject, List.of(actor1, actor2));
+
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.principal(principal)
+				.authorizationGrant(tokenExchangeAuthentication)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		assertThat(jwtClaimsSet.getClaims()).isNotEmpty();
+		assertThat(jwtClaimsSet.getClaims()).hasSize(2);
+		assertThat(jwtClaimsSet.getClaims().get("act")).isNotNull();
+		@SuppressWarnings("unchecked")
+		Map<String, Object> actClaim1 = (Map<String, Object>) jwtClaimsSet.getClaims().get("act");
+		assertThat(actClaim1.get(JwtClaimNames.ISS)).isEqualTo(ISSUER_1);
+		assertThat(actClaim1.get(JwtClaimNames.SUB)).isEqualTo("actor1");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> actClaim2 = (Map<String, Object>) actClaim1.get("act");
+		assertThat(actClaim2.get(JwtClaimNames.ISS)).isEqualTo(ISSUER_2);
+		assertThat(actClaim2.get(JwtClaimNames.SUB)).isEqualTo("actor2");
+	}
+
+	@Test
+	void customizeWhenPKIX509ClientCertificateAndCertificateBoundAccessTokensThenX5tClaimAdded() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.tokenSettings(
+						TokenSettings.builder()
+								.x509CertificateBoundAccessTokens(true)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.TLS_CLIENT_AUTH, TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE);
+		OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.registeredClient(registeredClient)
+				.authorizationGrant(clientCredentialsAuthentication)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		assertThat(jwtClaimsSet.getClaims()).isNotEmpty();
+		assertThat(jwtClaimsSet.getClaims()).hasSize(2);
+		Map<String, Object> cnfClaim = jwtClaimsSet.getClaim("cnf");
+		assertThat(cnfClaim).isNotEmpty();
+		assertThat(cnfClaim.get("x5t#S256")).isNotNull();
+	}
+
+	@Test
+	void customizeWhenSelfSignedX509ClientCertificateAndCertificateBoundAccessTokensThenX5tClaimAdded() {
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl("https://client.example.com/jwks")
+								.build()
+				)
+				.tokenSettings(
+						TokenSettings.builder()
+								.x509CertificateBoundAccessTokens(true)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.SELF_SIGNED_TLS_CLIENT_AUTH,
+				TestX509Certificates.DEMO_CLIENT_SELF_SIGNED_CERTIFICATE);
+		OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+		// @formatter:off
+		JwtEncodingContext tokenContext = JwtEncodingContext.with(this.jwsHeaderBuilder, this.jwtClaimsBuilder)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.registeredClient(registeredClient)
+				.authorizationGrant(clientCredentialsAuthentication)
+				.build();
+		// @formatter:on
+		DefaultOAuth2TokenCustomizers.jwtCustomizer().customize(tokenContext);
+		JwtClaimsSet jwtClaimsSet = this.jwtClaimsBuilder.build();
+		assertThat(jwtClaimsSet.getClaims()).isNotEmpty();
+		assertThat(jwtClaimsSet.getClaims()).hasSize(2);
+		Map<String, Object> cnfClaim = jwtClaimsSet.getClaim("cnf");
+		assertThat(cnfClaim).isNotEmpty();
+		assertThat(cnfClaim.get("x5t#S256")).isNotNull();
+	}
+
+}

+ 199 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/JwkSetTests.java

@@ -0,0 +1,199 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the JWK Set endpoint.
+ *
+ * @author Florian Berthe
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class JwkSetTests {
+
+	private static final String DEFAULT_JWK_SET_ENDPOINT_URI = "/oauth2/jwks";
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenJwkSetThenReturnKeys() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		assertJwkSetRequestThenReturnKeys(DEFAULT_JWK_SET_ENDPOINT_URI);
+	}
+
+	@Test
+	public void requestWhenJwkSetCustomEndpointThenReturnKeys() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomEndpoints.class).autowire();
+
+		assertJwkSetRequestThenReturnKeys(this.authorizationServerSettings.getJwkSetEndpoint());
+	}
+
+	@Test
+	public void requestWhenJwkSetRequestIncludesIssuerPathThenReturnKeys() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomEndpoints.class).autowire();
+
+		String issuer = "https://example.com:8443/issuer1";
+		assertJwkSetRequestThenReturnKeys(issuer.concat(this.authorizationServerSettings.getJwkSetEndpoint()));
+	}
+
+	private void assertJwkSetRequestThenReturnKeys(String jwkSetEndpointUri) throws Exception {
+		this.mvc.perform(get(jwkSetEndpointUri))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.keys").isNotEmpty())
+			.andExpect(jsonPath("$.keys").isArray());
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			return new JdbcRegisteredClientRepository(jdbcOperations);
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationCustomEndpoints extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder()
+				.jwkSetEndpoint("/test/jwks")
+				.multipleIssuersAllowed(true)
+				.build();
+		}
+
+	}
+
+}

+ 1509 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationCodeGrantTests.java

@@ -0,0 +1,1509 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.text.MessageFormat;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import com.jayway.jsonpath.JsonPath;
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.assertj.core.matcher.AssertionMatcher;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.lang.Nullable;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+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.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.crypto.keygen.Base64StringKeyGenerator;
+import org.springframework.security.crypto.keygen.StringKeyGenerator;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+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.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.endpoint.PkceParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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.authentication.OAuth2AuthorizationCodeRequestAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeRequestAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationContext;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationConsentAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeRequestAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationConsentAuthenticationConverter;
+import org.springframework.security.web.SecurityFilterChain;
+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.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.context.SecurityContextRepository;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+import org.springframework.web.util.UriUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OAuth 2.0 Authorization Code Grant.
+ *
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ * @author Dmitriy Dubson
+ * @author Steve Riesenberg
+ * @author Greg Li
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2AuthorizationCodeGrantTests {
+
+	private static final String DEFAULT_AUTHORIZATION_ENDPOINT_URI = "/oauth2/authorize";
+
+	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+
+	// See RFC 7636: Appendix B. Example for the S256 code_challenge_method
+	// https://tools.ietf.org/html/rfc7636#appendix-B
+	private static final String S256_CODE_VERIFIER = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+
+	private static final String S256_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+	private static final String AUTHORITIES_CLAIM = "authorities";
+
+	private static final String STATE_URL_UNENCODED = "awrD0fCnEcTUPFgmyy2SU89HZNcnAJ60ZW6l39YI0KyVjmIZ+004pwm9j55li7BoydXYysH4enZMF21Q";
+
+	private static final String STATE_URL_ENCODED = "awrD0fCnEcTUPFgmyy2SU89HZNcnAJ60ZW6l39YI0KyVjmIZ%2B004pwm9j55li7BoydXYysH4enZMF21Q";
+
+	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 EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static NimbusJwtEncoder jwtEncoder;
+
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
+	private static AuthorizationServerSettings authorizationServerSettings;
+
+	private static HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	private static AuthenticationConverter authorizationRequestConverter;
+
+	private static Consumer<List<AuthenticationConverter>> authorizationRequestConvertersConsumer;
+
+	private static AuthenticationProvider authorizationRequestAuthenticationProvider;
+
+	private static Consumer<List<AuthenticationProvider>> authorizationRequestAuthenticationProvidersConsumer;
+
+	private static AuthenticationSuccessHandler authorizationResponseHandler;
+
+	private static AuthenticationFailureHandler authorizationErrorResponseHandler;
+
+	private static SecurityContextRepository securityContextRepository;
+
+	private static String consentPage = "/oauth2/consent";
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@Autowired
+	private JwtDecoder jwtDecoder;
+
+	@Autowired(required = false)
+	private OAuth2TokenGenerator<?> tokenGenerator;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		jwtEncoder = new NimbusJwtEncoder(jwkSource);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		authorizationServerSettings = AuthorizationServerSettings.builder()
+			.authorizationEndpoint("/test/authorize")
+			.tokenEndpoint("/test/token")
+			.build();
+		authorizationRequestConverter = mock(AuthenticationConverter.class);
+		authorizationRequestConvertersConsumer = mock(Consumer.class);
+		authorizationRequestAuthenticationProvider = mock(AuthenticationProvider.class);
+		authorizationRequestAuthenticationProvidersConsumer = mock(Consumer.class);
+		authorizationResponseHandler = mock(AuthenticationSuccessHandler.class);
+		authorizationErrorResponseHandler = mock(AuthenticationFailureHandler.class);
+		securityContextRepository = spy(new HttpSessionSecurityContextRepository());
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@BeforeEach
+	public void setup() {
+		reset(securityContextRepository);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_authorization_consent");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenAuthorizationRequestNotAuthenticatedThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParams(getAuthorizationRequestParameters(registeredClient)))
+			.andExpect(status().isUnauthorized())
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenRegisteredClientMissingThenBadRequest() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+		this.mvc
+			.perform(
+					get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).params(getAuthorizationRequestParameters(registeredClient)))
+			.andExpect(status().isBadRequest())
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenAuthorizationRequestAuthenticatedThenRedirectToClient() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		assertAuthorizationRequestRedirectsToClient(DEFAULT_AUTHORIZATION_ENDPOINT_URI);
+	}
+
+	@Test
+	public void requestWhenAuthorizationRequestCustomEndpointThenRedirectToClient() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomEndpoints.class).autowire();
+
+		assertAuthorizationRequestRedirectsToClient(authorizationServerSettings.getAuthorizationEndpoint());
+	}
+
+	private void assertAuthorizationRequestRedirectsToClient(String authorizationEndpointUri) throws Exception {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
+			redirectUris.clear();
+			redirectUris.add("https://example.com/callback-1?param=encoded%20parameter%20value"); // gh-1011
+		}).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(authorizationEndpointUri).queryParams(authorizationRequestParameters).with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String redirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		String code = extractParameterFromRedirectUri(redirectedUrl, "code");
+		assertThat(redirectedUrl).isEqualTo(redirectUri + "&code=" + code + "&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorization).isNotNull();
+		assertThat(authorization.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+	}
+
+	@Test
+	public void requestWhenTokenRequestValidThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessTokenResponse accessTokenResponse = assertTokenRequestReturnsAccessTokenResponse(registeredClient,
+				authorization, DEFAULT_TOKEN_ENDPOINT_URI);
+
+		// Assert user authorities was propagated as claim in JWT
+		Jwt jwt = this.jwtDecoder.decode(accessTokenResponse.getAccessToken().getTokenValue());
+		List<String> authoritiesClaim = jwt.getClaim(AUTHORITIES_CLAIM);
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+		Set<String> userAuthorities = new HashSet<>();
+		for (GrantedAuthority authority : principal.getAuthorities()) {
+			userAuthorities.add(authority.getAuthority());
+		}
+
+		assertThat(authoritiesClaim).containsExactlyInAnyOrderElementsOf(userAuthorities);
+	}
+
+	@Test
+	public void requestWhenTokenRequestCustomEndpointThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomEndpoints.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		assertTokenRequestReturnsAccessTokenResponse(registeredClient, authorization,
+				authorizationServerSettings.getTokenEndpoint());
+	}
+
+	private OAuth2AccessTokenResponse assertTokenRequestReturnsAccessTokenResponse(RegisteredClient registeredClient,
+			OAuth2Authorization authorization, String tokenEndpointUri) throws Exception {
+		MvcResult mvcResult = this.mvc
+			.perform(post(tokenEndpointUri).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andReturn();
+
+		OAuth2Authorization accessTokenAuthorization = this.authorizationService.findById(authorization.getId());
+		assertThat(accessTokenAuthorization).isNotNull();
+		assertThat(accessTokenAuthorization.getAccessToken()).isNotNull();
+		assertThat(accessTokenAuthorization.getRefreshToken()).isNotNull();
+
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCodeToken = accessTokenAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCodeToken).isNotNull();
+		assertThat(authorizationCodeToken.getMetadata().get(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME))
+			.isEqualTo(true);
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		return accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+	}
+
+	@Test
+	public void requestWhenPublicClientWithPkceThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParams(getAuthorizationRequestParameters(registeredClient))
+				.queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE)
+				.queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256")
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorizationCodeAuthorization).isNotNull();
+		assertThat(authorizationCodeAuthorization.getAuthorizationGrantType())
+			.isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER))
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").doesNotExist())
+			.andExpect(jsonPath("$.scope").isNotEmpty());
+
+		OAuth2Authorization accessTokenAuthorization = this.authorizationService
+			.findById(authorizationCodeAuthorization.getId());
+		assertThat(accessTokenAuthorization).isNotNull();
+		assertThat(accessTokenAuthorization.getAccessToken()).isNotNull();
+
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCodeToken = accessTokenAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCodeToken).isNotNull();
+		assertThat(authorizationCodeToken.getMetadata().get(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME))
+			.isEqualTo(true);
+	}
+
+	// gh-1430
+	@Test
+	public void requestWhenPublicClientWithPkceAndCustomRefreshTokenGeneratorThenReturnRefreshToken() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithCustomRefreshTokenGenerator.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParams(getAuthorizationRequestParameters(registeredClient))
+				.queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE)
+				.queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256")
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		assertThat(redirectedUrl).matches("https://example.com\\?code=.{15,}&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorizationCodeAuthorization).isNotNull();
+		assertThat(authorizationCodeAuthorization.getAuthorizationGrantType())
+			.isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER))
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty());
+
+		OAuth2Authorization authorization = this.authorizationService.findById(authorizationCodeAuthorization.getId());
+		assertThat(authorization).isNotNull();
+		assertThat(authorization.getAccessToken()).isNotNull();
+		assertThat(authorization.getRefreshToken()).isNotNull();
+
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCodeToken = authorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCodeToken).isNotNull();
+		assertThat(authorizationCodeToken.getMetadata().get(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME))
+			.isEqualTo(true);
+	}
+
+	// gh-1680
+	@Test
+	public void requestWhenPublicClientWithPkceAndEmptyCodeThenBadRequest() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> tokenRequestParameters = new LinkedMultiValueMap<>();
+		tokenRequestParameters.set(OAuth2ParameterNames.GRANT_TYPE,
+				AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		tokenRequestParameters.set(OAuth2ParameterNames.CODE, "");
+		tokenRequestParameters.set(OAuth2ParameterNames.REDIRECT_URI,
+				registeredClient.getRedirectUris().iterator().next());
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(tokenRequestParameters)
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER))
+			.andExpect(status().isBadRequest());
+	}
+
+	@Test
+	public void requestWhenConfidentialClientWithPkceAndMissingCodeVerifierThenBadRequest() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE)
+				.queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256")
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorizationCodeAuthorization).isNotNull();
+		assertThat(authorizationCodeAuthorization.getAuthorizationGrantType())
+			.isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(status().isBadRequest());
+	}
+
+	// gh-1011
+	@Test
+	public void requestWhenConfidentialClientWithPkceAndMissingCodeChallengeThenErrorResponseEncoded()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		String redirectUri = "https://example.com/callback-1?param=encoded%20parameter%20value";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().redirectUris((redirectUris) -> {
+			redirectUris.clear();
+			redirectUris.add(redirectUri);
+		}).clientSettings(ClientSettings.builder().requireProofKey(true).build()).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = redirectUri + "&" + "error=invalid_request&" + "error_description="
+				+ UriUtils.encode("OAuth 2.0 Parameter: code_challenge", StandardCharsets.UTF_8) + "&" + "error_uri="
+				+ UriUtils.encode("https://datatracker.ietf.org/doc/html/rfc7636#section-4.4.1", StandardCharsets.UTF_8)
+				+ "&" + "state=" + STATE_URL_ENCODED;
+		assertThat(redirectedUrl).isEqualTo(expectedRedirectUri);
+	}
+
+	@Test
+	public void requestWhenConfidentialClientWithPkceAndMissingCodeChallengeButCodeVerifierProvidedThenBadRequest()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+		assertThat(authorizationCodeAuthorization).isNotNull();
+		assertThat(authorizationCodeAuthorization.getAuthorizationGrantType())
+			.isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER)
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(status().isBadRequest());
+	}
+
+	@Test
+	public void requestWhenCustomTokenGeneratorThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithTokenGenerator.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(status().isOk());
+
+		verify(this.tokenGenerator, times(2)).generate(any());
+	}
+
+	@Test
+	public void requestWhenRequiresConsentThenDisplaysConsentPage() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
+			scopes.clear();
+			scopes.add("message.read");
+			scopes.add("message.write");
+		}).clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		String consentPage = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParams(getAuthorizationRequestParameters(registeredClient))
+				.with(user("user")))
+			.andExpect(status().is2xxSuccessful())
+			.andReturn()
+			.getResponse()
+			.getContentAsString();
+
+		assertThat(consentPage).contains("Consent required");
+		assertThat(consentPage).contains(scopeCheckbox("message.read"));
+		assertThat(consentPage).contains(scopeCheckbox("message.write"));
+	}
+
+	@Test
+	public void requestWhenConsentRequestThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
+			scopes.clear();
+			scopes.add("message.read");
+			scopes.add("message.write");
+		}).clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName("user")
+			.build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationRequest updatedAuthorizationRequest = OAuth2AuthorizationRequest.from(authorizationRequest)
+			.state(STATE_URL_UNENCODED)
+			.build();
+		authorization = OAuth2Authorization.from(authorization)
+			.attribute(OAuth2AuthorizationRequest.class.getName(), updatedAuthorizationRequest)
+			.build();
+		this.authorizationService.save(authorization);
+
+		MvcResult mvcResult = this.mvc
+			.perform(post(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(OAuth2ParameterNames.SCOPE, "message.read")
+				.param(OAuth2ParameterNames.SCOPE, "message.write")
+				.param(OAuth2ParameterNames.STATE, authorization.<String>getAttribute(OAuth2ParameterNames.STATE))
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		assertThat(redirectedUrl)
+			.matches(authorizationRequest.getRedirectUri() + "\\?code=.{15,}&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenCustomConsentPageConfiguredThenRedirect() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomConsentPage.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scopes((scopes) -> {
+			scopes.clear();
+			scopes.add("message.read");
+			scopes.add("message.write");
+		}).clientSettings(ClientSettings.builder().requireAuthorizationConsent(true).build()).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParams(getAuthorizationRequestParameters(registeredClient))
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		assertThat(redirectedUrl).matches("http://localhost/oauth2/consent\\?scope=.+&client_id=.+&state=.+");
+
+		String locationHeader = URLDecoder.decode(redirectedUrl, StandardCharsets.UTF_8.name());
+		UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build();
+		MultiValueMap<String, String> redirectQueryParams = uriComponents.getQueryParams();
+
+		assertThat(uriComponents.getPath()).isEqualTo(consentPage);
+		assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.SCOPE)).isEqualTo("message.read message.write");
+		assertThat(redirectQueryParams.getFirst(OAuth2ParameterNames.CLIENT_ID))
+			.isEqualTo(registeredClient.getClientId());
+
+		String state = extractParameterFromRedirectUri(redirectedUrl, "state");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(state, STATE_TOKEN_TYPE);
+		assertThat(authorization).isNotNull();
+	}
+
+	@Test
+	public void requestWhenCustomConsentCustomizerConfiguredThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomConsentRequest.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientSettings(ClientSettings.builder()
+				.requireAuthorizationConsent(true)
+				.setting("custom.allowed-authorities", "authority-1 authority-2")
+				.build())
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationRequest updatedAuthorizationRequest = OAuth2AuthorizationRequest.from(authorizationRequest)
+			.state(STATE_URL_UNENCODED)
+			.build();
+		authorization = OAuth2Authorization.from(authorization)
+			.attribute(OAuth2AuthorizationRequest.class.getName(), updatedAuthorizationRequest)
+			.build();
+		this.authorizationService.save(authorization);
+
+		MvcResult mvcResult = this.mvc
+			.perform(post(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param("authority", "authority-1 authority-2")
+				.param(OAuth2ParameterNames.STATE, authorization.<String>getAttribute(OAuth2ParameterNames.STATE))
+				.with(user("principal")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		assertThat(redirectedUrl)
+			.matches(authorizationRequest.getRedirectUri() + "\\?code=.{15,}&state=" + STATE_URL_ENCODED);
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.access_token").value(new AssertionMatcher<String>() {
+				@Override
+				public void assertion(String accessToken) throws AssertionError {
+					Jwt jwt = OAuth2AuthorizationCodeGrantTests.this.jwtDecoder.decode(accessToken);
+					assertThat(jwt.getClaimAsStringList(AUTHORITIES_CLAIM)).containsExactlyInAnyOrder("authority-1",
+							"authority-2");
+				}
+			}))
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").doesNotExist())
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenAuthorizationEndpointCustomizedThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomAuthorizationEndpoint.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principalName", "password");
+		OAuth2AuthorizationCode authorizationCode = new OAuth2AuthorizationCode("code", Instant.now(),
+				Instant.now().plus(5, ChronoUnit.MINUTES));
+		OAuth2AuthorizationCodeRequestAuthenticationToken authorizationCodeRequestAuthenticationResult = new OAuth2AuthorizationCodeRequestAuthenticationToken(
+				"https://provider.com/oauth2/authorize", registeredClient.getClientId(), principal, authorizationCode,
+				registeredClient.getRedirectUris().iterator().next(), STATE_URL_UNENCODED,
+				registeredClient.getScopes());
+		given(authorizationRequestConverter.convert(any())).willReturn(authorizationCodeRequestAuthenticationResult);
+		given(authorizationRequestAuthenticationProvider
+			.supports(eq(OAuth2AuthorizationCodeRequestAuthenticationToken.class))).willReturn(true);
+		given(authorizationRequestAuthenticationProvider.authenticate(any()))
+			.willReturn(authorizationCodeRequestAuthenticationResult);
+
+		this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).params(getAuthorizationRequestParameters(registeredClient))
+				.with(user("user")))
+			.andExpect(status().isOk());
+
+		verify(authorizationRequestConverter).convert(any());
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authorizationRequestConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).allMatch((converter) -> converter == authorizationRequestConverter
+				|| converter instanceof OAuth2AuthorizationCodeRequestAuthenticationConverter
+				|| converter instanceof OAuth2AuthorizationConsentAuthenticationConverter);
+
+		verify(authorizationRequestAuthenticationProvider)
+			.authenticate(eq(authorizationCodeRequestAuthenticationResult));
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authorizationRequestAuthenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders)
+			.allMatch((provider) -> provider == authorizationRequestAuthenticationProvider
+					|| provider instanceof OAuth2AuthorizationCodeRequestAuthenticationProvider
+					|| provider instanceof OAuth2AuthorizationConsentAuthenticationProvider);
+
+		verify(authorizationResponseHandler).onAuthenticationSuccess(any(), any(),
+				eq(authorizationCodeRequestAuthenticationResult));
+	}
+
+	// gh-482
+	@Test
+	public void requestWhenClientObtainsAccessTokenThenClientAuthenticationNotPersisted() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithSecurityContextRepository.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParams(getAuthorizationRequestParameters(registeredClient))
+				.queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE)
+				.queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256")
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		ArgumentCaptor<org.springframework.security.core.context.SecurityContext> securityContextCaptor = ArgumentCaptor
+			.forClass(org.springframework.security.core.context.SecurityContext.class);
+		verify(securityContextRepository, times(1)).saveContext(securityContextCaptor.capture(), any(), any());
+		assertThat(securityContextCaptor.getValue().getAuthentication())
+			.isInstanceOf(UsernamePasswordAuthenticationToken.class);
+		reset(securityContextRepository);
+
+		String authorizationCode = extractParameterFromRedirectUri(mvcResult.getResponse().getRedirectedUrl(), "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER))
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").doesNotExist())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andReturn();
+
+		org.springframework.security.core.context.SecurityContext securityContext = securityContextRepository
+			.loadDeferredContext(mvcResult.getRequest())
+			.get();
+		assertThat(securityContext.getAuthentication()).isNull();
+	}
+
+	@Test
+	public void requestWhenAuthorizationAndTokenRequestIncludesIssuerPathThenIssuerResolvedWithPath() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		MvcResult mvcResult = this.mvc
+			.perform(get(issuer.concat(DEFAULT_AUTHORIZATION_ENDPOINT_URI))
+				.queryParams(getAuthorizationRequestParameters(registeredClient))
+				.queryParam(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE)
+				.queryParam(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256")
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		String authorizationCode = extractParameterFromRedirectUri(mvcResult.getResponse().getRedirectedUrl(), "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		this.mvc
+			.perform(post(issuer.concat(DEFAULT_TOKEN_ENDPOINT_URI))
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER))
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").doesNotExist())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andReturn();
+
+		ArgumentCaptor<OAuth2TokenContext> tokenContextCaptor = ArgumentCaptor.forClass(OAuth2TokenContext.class);
+		verify(this.tokenGenerator).generate(tokenContextCaptor.capture());
+		OAuth2TokenContext tokenContext = tokenContextCaptor.getValue();
+		assertThat(tokenContext.getAuthorizationServerContext().getIssuer()).isEqualTo(issuer);
+	}
+
+	@Test
+	public void requestWhenTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient))
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+		String jwkThumbprintClaim = (String) cnfClaims.get("jkt");
+		assertThat(jwkThumbprintClaim).isEqualTo(TestJwks.DEFAULT_EC_JWK.toPublicJWK().computeThumbprint().toString());
+	}
+
+	@Test
+	public void requestWhenPushedAuthorizationRequestThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithPushedAuthorizationRequests.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MvcResult mvcResult = this.mvc
+			.perform(post("/oauth2/par").params(getAuthorizationRequestParameters(registeredClient))
+				.param(PkceParameterNames.CODE_CHALLENGE, S256_CODE_CHALLENGE)
+				.param(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256")
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(status().isCreated())
+			.andExpect(jsonPath("$.request_uri").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andReturn();
+
+		String requestUri = JsonPath.read(mvcResult.getResponse().getContentAsString(), "$.request_uri");
+
+		mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI)
+				.queryParam(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.queryParam(OAuth2ParameterNames.REQUEST_URI, requestUri)
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		String authorizationCode = extractParameterFromRedirectUri(mvcResult.getResponse().getRedirectedUrl(), "code");
+		OAuth2Authorization authorizationCodeAuthorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient, authorizationCodeAuthorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(PkceParameterNames.CODE_VERIFIER, S256_CODE_VERIFIER)
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(registeredClient)))
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andReturn();
+
+		OAuth2Authorization accessTokenAuthorization = this.authorizationService
+			.findById(authorizationCodeAuthorization.getId());
+		assertThat(accessTokenAuthorization).isNotNull();
+		assertThat(accessTokenAuthorization.getAccessToken()).isNotNull();
+
+		OAuth2Authorization.Token<OAuth2AuthorizationCode> authorizationCodeToken = accessTokenAuthorization
+			.getToken(OAuth2AuthorizationCode.class);
+		assertThat(authorizationCodeToken).isNotNull();
+		assertThat(authorizationCodeToken.getMetadata().get(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME))
+			.isEqualTo(true);
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+	private static MultiValueMap<String, String> getAuthorizationRequestParameters(RegisteredClient registeredClient) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+		parameters.set(OAuth2ParameterNames.STATE, STATE_URL_UNENCODED);
+		return parameters;
+	}
+
+	private static MultiValueMap<String, String> getTokenRequestParameters(RegisteredClient registeredClient,
+			OAuth2Authorization authorization) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.CODE,
+				authorization.getToken(OAuth2AuthorizationCode.class).getToken().getTokenValue());
+		parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
+		return parameters;
+	}
+
+	private static String getAuthorizationHeader(RegisteredClient registeredClient) throws Exception {
+		String clientId = registeredClient.getClientId();
+		String clientSecret = registeredClient.getClientSecret();
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + clientSecret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return "Basic " + new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	private static String scopeCheckbox(String scope) {
+		return MessageFormat.format(
+				"<input class=\"form-check-input\" type=\"checkbox\" name=\"scope\" value=\"{0}\" id=\"{0}\">", scope);
+	}
+
+	private String extractParameterFromRedirectUri(String redirectUri, String param)
+			throws UnsupportedEncodingException {
+		String locationHeader = URLDecoder.decode(redirectUri, StandardCharsets.UTF_8.name());
+		UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build();
+		return uriComponents.getQueryParams().getFirst(param);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			jdbcRegisteredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			return jdbcRegisteredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
+			return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+		}
+
+		@Bean
+		OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
+			return (context) -> {
+				if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(context.getAuthorizationGrantType())
+						&& OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+					Authentication principal = context.getPrincipal();
+					Set<String> authorities = new HashSet<>();
+					for (GrantedAuthority authority : principal.getAuthorities()) {
+						authorities.add(authority.getAuthority());
+					}
+					context.getClaims().claim(AUTHORITIES_CLAIM, authorities);
+				}
+			};
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithCustomRefreshTokenGenerator
+			extends AuthorizationServerConfiguration {
+
+		@Bean
+		JwtEncoder jwtEncoder() {
+			return jwtEncoder;
+		}
+
+		@Bean
+		OAuth2TokenGenerator<?> tokenGenerator() {
+			JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder());
+			jwtGenerator.setJwtCustomizer(jwtCustomizer());
+			OAuth2TokenGenerator<OAuth2RefreshToken> refreshTokenGenerator = new CustomRefreshTokenGenerator();
+			return new DelegatingOAuth2TokenGenerator(jwtGenerator, refreshTokenGenerator);
+		}
+
+		private static final class CustomRefreshTokenGenerator implements OAuth2TokenGenerator<OAuth2RefreshToken> {
+
+			private final StringKeyGenerator refreshTokenGenerator = new Base64StringKeyGenerator(
+					Base64.getUrlEncoder().withoutPadding(), 96);
+
+			@Nullable
+			@Override
+			public OAuth2RefreshToken generate(OAuth2TokenContext context) {
+				if (!OAuth2TokenType.REFRESH_TOKEN.equals(context.getTokenType())) {
+					return null;
+				}
+				Instant issuedAt = Instant.now();
+				Instant expiresAt = issuedAt
+					.plus(context.getRegisteredClient().getTokenSettings().getRefreshTokenTimeToLive());
+				return new OAuth2RefreshToken(this.refreshTokenGenerator.generateKey(), issuedAt, expiresAt);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithSecurityContextRepository
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, Customizer.withDefaults())
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					)
+					.securityContext((securityContext) ->
+							securityContext.securityContextRepository(securityContextRepository));
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithTokenGenerator extends AuthorizationServerConfiguration {
+
+		@Bean
+		JwtEncoder jwtEncoder() {
+			return jwtEncoder;
+		}
+
+		@Bean
+		OAuth2TokenGenerator<?> tokenGenerator() {
+			JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder());
+			jwtGenerator.setJwtCustomizer(jwtCustomizer());
+			OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
+			OAuth2TokenGenerator<OAuth2Token> delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(
+					jwtGenerator, refreshTokenGenerator);
+			return spy(new OAuth2TokenGenerator<OAuth2Token>() {
+				@Override
+				public OAuth2Token generate(OAuth2TokenContext context) {
+					return delegatingTokenGenerator.generate(context);
+				}
+			});
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationCustomEndpoints extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return authorizationServerSettings;
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomConsentPage extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.authorizationEndpoint((authorizationEndpoint) ->
+											authorizationEndpoint.consentPage(consentPage))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomConsentRequest extends AuthorizationServerConfiguration {
+
+		@Autowired
+		private OAuth2AuthorizationConsentService authorizationConsentService;
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.authorizationEndpoint((authorizationEndpoint) ->
+											authorizationEndpoint.authenticationProviders(configureAuthenticationProviders()))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		@Bean
+		@Override
+		OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
+			return (context) -> {
+				if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(context.getAuthorizationGrantType())
+						&& OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) {
+					OAuth2AuthorizationConsent authorizationConsent = this.authorizationConsentService
+						.findById(context.getRegisteredClient().getId(), context.getPrincipal().getName());
+
+					Set<String> authorities = new HashSet<>();
+					for (GrantedAuthority authority : authorizationConsent.getAuthorities()) {
+						authorities.add(authority.getAuthority());
+					}
+					context.getClaims().claim(AUTHORITIES_CLAIM, authorities);
+				}
+			};
+		}
+
+		private Consumer<List<AuthenticationProvider>> configureAuthenticationProviders() {
+			return (authenticationProviders) -> authenticationProviders.forEach((authenticationProvider) -> {
+				if (authenticationProvider instanceof OAuth2AuthorizationConsentAuthenticationProvider) {
+					((OAuth2AuthorizationConsentAuthenticationProvider) authenticationProvider)
+						.setAuthorizationConsentCustomizer(new AuthorizationConsentCustomizer());
+				}
+			});
+		}
+
+		static class AuthorizationConsentCustomizer
+				implements Consumer<OAuth2AuthorizationConsentAuthenticationContext> {
+
+			@Override
+			public void accept(
+					OAuth2AuthorizationConsentAuthenticationContext authorizationConsentAuthenticationContext) {
+				OAuth2AuthorizationConsent.Builder authorizationConsentBuilder = authorizationConsentAuthenticationContext
+					.getAuthorizationConsent();
+				OAuth2AuthorizationConsentAuthenticationToken authorizationConsentAuthentication = authorizationConsentAuthenticationContext
+					.getAuthentication();
+				Map<String, Object> additionalParameters = authorizationConsentAuthentication.getAdditionalParameters();
+				RegisteredClient registeredClient = authorizationConsentAuthenticationContext.getRegisteredClient();
+				ClientSettings clientSettings = registeredClient.getClientSettings();
+
+				Set<String> requestedAuthorities = authorities((String) additionalParameters.get("authority"));
+				Set<String> allowedAuthorities = authorities(clientSettings.getSetting("custom.allowed-authorities"));
+				for (String requestedAuthority : requestedAuthorities) {
+					if (allowedAuthorities.contains(requestedAuthority)) {
+						authorizationConsentBuilder.authority(new SimpleGrantedAuthority(requestedAuthority));
+					}
+				}
+			}
+
+			private static Set<String> authorities(String param) {
+				Set<String> authorities = new HashSet<>();
+				if (param != null) {
+					List<String> authorityValues = Arrays.asList(param.split(" "));
+					authorities.addAll(authorityValues);
+				}
+
+				return authorities;
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomAuthorizationEndpoint extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.authorizationEndpoint((authorizationEndpoint) ->
+											authorizationEndpoint
+													.authorizationRequestConverter(authorizationRequestConverter)
+													.authorizationRequestConverters(authorizationRequestConvertersConsumer)
+													.authenticationProvider(authorizationRequestAuthenticationProvider)
+													.authenticationProviders(authorizationRequestAuthenticationProvidersConsumer)
+													.authorizationResponseHandler(authorizationResponseHandler)
+													.errorResponseHandler(authorizationErrorResponseHandler))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithMultipleIssuersAllowed
+			extends AuthorizationServerConfigurationWithTokenGenerator {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithPushedAuthorizationRequests
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.pushedAuthorizationRequestEndpoint(Customizer.withDefaults())
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+}

+ 229 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2AuthorizationServerMetadataTests.java

@@ -0,0 +1,229 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.util.function.Consumer;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadata;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadataClaimNames;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.CoreMatchers.hasItems;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OAuth 2.0 Authorization Server Metadata endpoint.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2AuthorizationServerMetadataTests {
+
+	private static final String DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI = "/.well-known/oauth-authorization-server";
+
+	private static final String ISSUER = "https://example.com";
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@BeforeAll
+	public static void setupClass() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenAuthorizationServerMetadataRequestAndIssuerSetThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath("issuer").value(ISSUER))
+			.andReturn();
+	}
+
+	@Test
+	public void requestWhenAuthorizationServerMetadataRequestIncludesIssuerPathThenMetadataResponseHasIssuerPath()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		String host = "https://example.com:8443";
+
+		String issuerPath = "/issuer1";
+		String issuer = host.concat(issuerPath);
+		this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI).concat(issuerPath)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath("issuer").value(issuer))
+			.andReturn();
+
+		issuerPath = "/path1/issuer2";
+		issuer = host.concat(issuerPath);
+		this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI).concat(issuerPath)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath("issuer").value(issuer))
+			.andReturn();
+
+		issuerPath = "/path1/path2/issuer3";
+		issuer = host.concat(issuerPath);
+		this.mvc.perform(get(host.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI).concat(issuerPath)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath("issuer").value(issuer))
+			.andReturn();
+	}
+
+	// gh-616
+	@Test
+	public void requestWhenAuthorizationServerMetadataRequestAndMetadataCustomizerSetThenReturnCustomMetadataResponse()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMetadataCustomizer.class).autowire();
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OAUTH2_AUTHORIZATION_SERVER_METADATA_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED,
+					hasItems("scope1", "scope2")));
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+			JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			registeredClientRepository.save(registeredClient);
+			return registeredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER).build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithMetadataCustomizer extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.authorizationServerMetadataEndpoint((authorizationServerMetadataEndpoint) ->
+											authorizationServerMetadataEndpoint
+													.authorizationServerMetadataCustomizer(authorizationServerMetadataCustomizer()))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		private Consumer<OAuth2AuthorizationServerMetadata.Builder> authorizationServerMetadataCustomizer() {
+			return (authorizationServerMetadata) -> authorizationServerMetadata.scope("scope1").scope("scope2");
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithMultipleIssuersAllowed extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+}

+ 688 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2ClientCredentialsGrantTests.java

@@ -0,0 +1,688 @@
+/*
+ * Copyright 2020-2025 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.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.crypto.factory.PasswordEncoderFactories;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.authentication.ClientSecretAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.JwtClientAssertionAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AccessTokenAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientCredentialsAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2DeviceCodeAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenExchangeAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.PublicClientAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.X509ClientCertificateAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.util.TestX509Certificates;
+import org.springframework.security.oauth2.server.authorization.web.authentication.ClientSecretBasicAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.ClientSecretPostAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.JwtClientAssertionAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2ClientCredentialsAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2DeviceCodeAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2RefreshTokenAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenExchangeAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.PublicClientAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.web.authentication.X509ClientCertificateAuthenticationConverter;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.x509;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OAuth 2.0 Client Credentials Grant.
+ *
+ * @author Alexey Nesterov
+ * @author Joe Grandja
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2ClientCredentialsGrantTests {
+
+	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
+
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
+	private static AuthenticationConverter authenticationConverter;
+
+	private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
+
+	private static AuthenticationProvider authenticationProvider;
+
+	private static Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer;
+
+	private static AuthenticationSuccessHandler authenticationSuccessHandler;
+
+	private static AuthenticationFailureHandler authenticationFailureHandler;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		jwtCustomizer = mock(OAuth2TokenCustomizer.class);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		authenticationConverter = mock(AuthenticationConverter.class);
+		authenticationConvertersConsumer = mock(Consumer.class);
+		authenticationProvider = mock(AuthenticationProvider.class);
+		authenticationProvidersConsumer = mock(Consumer.class);
+		authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@SuppressWarnings("unchecked")
+	@BeforeEach
+	public void setup() {
+		reset(jwtCustomizer);
+		reset(authenticationConverter);
+		reset(authenticationConvertersConsumer);
+		reset(authenticationProvider);
+		reset(authenticationProvidersConsumer);
+		reset(authenticationSuccessHandler);
+		reset(authenticationFailureHandler);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenTokenRequestNotAuthenticatedThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		this.mvc
+			.perform(MockMvcRequestBuilders.post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()))
+			.andExpect(status().isUnauthorized());
+	}
+
+	@Test
+	public void requestWhenTokenRequestValidThenTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		verify(jwtCustomizer).customize(any());
+	}
+
+	@Test
+	public void requestWhenTokenRequestPostsClientCredentialsThenTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(OAuth2ParameterNames.CLIENT_SECRET, registeredClient.getClientSecret()))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		verify(jwtCustomizer).customize(any());
+	}
+
+	@Test
+	public void requestWhenTokenRequestPostsClientCredentialsAndRequiresUpgradingThenClientSecretUpgraded()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomPasswordEncoder.class).autowire();
+
+		String clientSecret = "secret-2";
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2()
+			.clientSecret("{noop}" + clientSecret)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(OAuth2ParameterNames.CLIENT_SECRET, clientSecret))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		verify(jwtCustomizer).customize(any());
+		RegisteredClient updatedRegisteredClient = this.registeredClientRepository
+			.findByClientId(registeredClient.getClientId());
+		assertThat(updatedRegisteredClient.getClientSecret()).startsWith("{bcrypt}");
+	}
+
+	@Test
+	public void requestWhenTokenRequestWithPKIX509ClientCertificateThenTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2()
+				.clientAuthenticationMethod(ClientAuthenticationMethod.TLS_CLIENT_AUTH)
+				.clientSettings(
+						ClientSettings.builder()
+								.x509CertificateSubjectDN(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE[0].getSubjectX500Principal().getName())
+								.build()
+				)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).with(x509(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2"))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		verify(jwtCustomizer).customize(any());
+	}
+
+	// gh-1635
+	@Test
+	public void requestWhenTokenRequestIncludesBasicClientCredentialsAndX509ClientCertificateThenTokenResponse()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).with(x509(TestX509Certificates.DEMO_CLIENT_PKI_CERTIFICATE))
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		verify(jwtCustomizer).customize(any());
+	}
+
+	@Test
+	public void requestWhenTokenEndpointCustomizedThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomTokenEndpoint.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2ClientCredentialsAuthenticationToken clientCredentialsAuthentication = new OAuth2ClientCredentialsAuthenticationToken(
+				clientPrincipal, null, null);
+		given(authenticationConverter.convert(any())).willReturn(clientCredentialsAuthentication);
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "token",
+				Instant.now(), Instant.now().plus(Duration.ofHours(1)));
+		OAuth2AccessTokenAuthenticationToken accessTokenAuthentication = new OAuth2AccessTokenAuthenticationToken(
+				registeredClient, clientPrincipal, accessToken);
+		given(authenticationProvider.supports(eq(OAuth2ClientCredentialsAuthenticationToken.class))).willReturn(true);
+		given(authenticationProvider.authenticate(any())).willReturn(accessTokenAuthentication);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		verify(authenticationConverter).convert(any());
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).allMatch((converter) -> converter == authenticationConverter
+				|| converter instanceof OAuth2AuthorizationCodeAuthenticationConverter
+				|| converter instanceof OAuth2RefreshTokenAuthenticationConverter
+				|| converter instanceof OAuth2ClientCredentialsAuthenticationConverter
+				|| converter instanceof OAuth2DeviceCodeAuthenticationConverter
+				|| converter instanceof OAuth2TokenExchangeAuthenticationConverter);
+
+		verify(authenticationProvider).authenticate(eq(clientCredentialsAuthentication));
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders).allMatch((provider) -> provider == authenticationProvider
+				|| provider instanceof OAuth2AuthorizationCodeAuthenticationProvider
+				|| provider instanceof OAuth2RefreshTokenAuthenticationProvider
+				|| provider instanceof OAuth2ClientCredentialsAuthenticationProvider
+				|| provider instanceof OAuth2DeviceCodeAuthenticationProvider
+				|| provider instanceof OAuth2TokenExchangeAuthenticationProvider);
+
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(accessTokenAuthentication));
+	}
+
+	@Test
+	public void requestWhenClientAuthenticationCustomizedThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomClientAuthentication.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				new ClientAuthenticationMethod("custom"), null);
+		given(authenticationConverter.convert(any())).willReturn(clientPrincipal);
+		given(authenticationProvider.supports(eq(OAuth2ClientAuthenticationToken.class))).willReturn(true);
+		given(authenticationProvider.authenticate(any())).willReturn(clientPrincipal);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).param(OAuth2ParameterNames.GRANT_TYPE,
+					AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()))
+			.andExpect(status().isOk());
+
+		verify(authenticationConverter).convert(any());
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).allMatch((converter) -> converter == authenticationConverter
+				|| converter instanceof JwtClientAssertionAuthenticationConverter
+				|| converter instanceof ClientSecretBasicAuthenticationConverter
+				|| converter instanceof ClientSecretPostAuthenticationConverter
+				|| converter instanceof PublicClientAuthenticationConverter
+				|| converter instanceof X509ClientCertificateAuthenticationConverter);
+
+		verify(authenticationProvider).authenticate(eq(clientPrincipal));
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders).allMatch((provider) -> provider == authenticationProvider
+				|| provider instanceof JwtClientAssertionAuthenticationProvider
+				|| provider instanceof X509ClientCertificateAuthenticationProvider
+				|| provider instanceof ClientSecretAuthenticationProvider
+				|| provider instanceof PublicClientAuthenticationProvider);
+
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(clientPrincipal));
+	}
+
+	@Test
+	public void requestWhenTokenRequestIncludesIssuerPathThenIssuerResolvedWithPath() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		this.mvc
+			.perform(post(issuer.concat(DEFAULT_TOKEN_ENDPOINT_URI))
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1 scope2"));
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(jwtCustomizer).customize(jwtEncodingContextCaptor.capture());
+		JwtEncodingContext jwtEncodingContext = jwtEncodingContextCaptor.getValue();
+		assertThat(jwtEncodingContext.getAuthorizationServerContext().getIssuer()).isEqualTo(issuer);
+	}
+
+	@Test
+	public void requestWhenTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1 scope2")
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret()))
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + secret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			jdbcRegisteredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			return jdbcRegisteredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
+			return jwtCustomizer;
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomTokenEndpoint extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.tokenEndpoint((tokenEndpoint) ->
+											tokenEndpoint
+													.accessTokenRequestConverter(authenticationConverter)
+													.accessTokenRequestConverters(authenticationConvertersConsumer)
+													.authenticationProvider(authenticationProvider)
+													.authenticationProviders(authenticationProvidersConsumer)
+													.accessTokenResponseHandler(authenticationSuccessHandler)
+													.errorResponseHandler(authenticationFailureHandler))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomPasswordEncoder extends AuthorizationServerConfiguration {
+
+		@Override
+		PasswordEncoder passwordEncoder() {
+			return PasswordEncoderFactories.createDelegatingPasswordEncoder();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomClientAuthentication extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			authenticationSuccessHandler = spy(authenticationSuccessHandler());
+
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.clientAuthentication((clientAuthentication) ->
+											clientAuthentication
+													.authenticationConverter(authenticationConverter)
+													.authenticationConverters(authenticationConvertersConsumer)
+													.authenticationProvider(authenticationProvider)
+													.authenticationProviders(authenticationProvidersConsumer)
+													.authenticationSuccessHandler(authenticationSuccessHandler)
+													.errorResponseHandler(authenticationFailureHandler))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		private AuthenticationSuccessHandler authenticationSuccessHandler() {
+			return new AuthenticationSuccessHandler() {
+				@Override
+				public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
+						Authentication authentication) throws IOException, ServletException {
+					org.springframework.security.core.context.SecurityContext securityContext = SecurityContextHolder
+						.createEmptyContext();
+					securityContext.setAuthentication(authentication);
+					SecurityContextHolder.setContext(securityContext);
+				}
+			};
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithMultipleIssuersAllowed extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+}

+ 695 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2DeviceCodeGrantTests.java

@@ -0,0 +1,695 @@
+/*
+ * Copyright 2020-2025 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.security.Principal;
+import java.time.Instant;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+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.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2DeviceCode;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2UserCode;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2DeviceAuthorizationResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.core.http.converter.OAuth2DeviceAuthorizationResponseHttpMessageConverter;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsent;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for OAuth 2.0 Device Grant.
+ *
+ * @author Steve Riesenberg
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2DeviceCodeGrantTests {
+
+	private static final String DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI = "/oauth2/device_authorization";
+
+	private static final String DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI = "/oauth2/device_verification";
+
+	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+
+	private static final OAuth2TokenType DEVICE_CODE_TOKEN_TYPE = new OAuth2TokenType(OAuth2ParameterNames.DEVICE_CODE);
+
+	private static final String USER_CODE = "ABCD-EFGH";
+
+	private static final String STATE = "123";
+
+	private static final String DEVICE_CODE = "abc-XYZ";
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
+	private static final HttpMessageConverter<OAuth2DeviceAuthorizationResponse> deviceAuthorizationResponseHttpMessageConverter = new OAuth2DeviceAuthorizationResponseHttpMessageConverter();
+
+	private static final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@Autowired
+	private OAuth2AuthorizationConsentService authorizationConsentService;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		// @formatter:off
+		db = new EmbeddedDatabaseBuilder()
+				.generateUniqueName(true)
+				.setType(EmbeddedDatabaseType.HSQL)
+				.setScriptEncoding("UTF-8")
+				.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+				.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")
+				.addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+				.build();
+		// @formatter:on
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_authorization_consent");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenDeviceAuthorizationRequestNotAuthenticatedThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI)
+				.params(parameters))
+				.andExpect(status().isUnauthorized());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenRegisteredClientMissingThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI)
+				.params(parameters)
+				.headers(withClientAuth(registeredClient)))
+				.andExpect(status().isUnauthorized());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenDeviceAuthorizationRequestValidThenReturnDeviceAuthorizationResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(issuer.concat(DEFAULT_DEVICE_AUTHORIZATION_ENDPOINT_URI))
+				.params(parameters)
+				.headers(withClientAuth(registeredClient)))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.device_code").isNotEmpty())
+				.andExpect(jsonPath("$.user_code").isNotEmpty())
+				.andExpect(jsonPath("$.expires_in").isNumber())
+				.andExpect(jsonPath("$.verification_uri").isNotEmpty())
+				.andExpect(jsonPath("$.verification_uri_complete").isNotEmpty())
+				.andReturn();
+		// @formatter:on
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.OK);
+		OAuth2DeviceAuthorizationResponse deviceAuthorizationResponse = deviceAuthorizationResponseHttpMessageConverter
+			.read(OAuth2DeviceAuthorizationResponse.class, httpResponse);
+		String userCode = deviceAuthorizationResponse.getUserCode().getTokenValue();
+		assertThat(userCode).matches("[A-Z]{4}-[A-Z]{4}");
+		assertThat(deviceAuthorizationResponse.getVerificationUri())
+			.isEqualTo("https://example.com:8443/oauth2/device_verification");
+		assertThat(deviceAuthorizationResponse.getVerificationUriComplete())
+			.isEqualTo("https://example.com:8443/oauth2/device_verification?user_code=" + userCode);
+
+		String deviceCode = deviceAuthorizationResponse.getDeviceCode().getTokenValue();
+		OAuth2Authorization authorization = this.authorizationService.findByToken(deviceCode, DEVICE_CODE_TOKEN_TYPE);
+		assertThat(authorization.getToken(OAuth2DeviceCode.class)).isNotNull();
+		assertThat(authorization.getToken(OAuth2UserCode.class)).isNotNull();
+	}
+
+	@Test
+	public void requestWhenDeviceVerificationRequestUnauthenticatedThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(registeredClient.getClientId())
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt))
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE);
+
+		// @formatter:off
+		this.mvc.perform(get(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI)
+				.queryParams(parameters))
+				.andExpect(status().isUnauthorized());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenDeviceVerificationRequestValidThenDisplaysConsentPage() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(registeredClient.getClientId())
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt))
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(get(issuer.concat(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI))
+				.queryParams(parameters)
+				.with(user("user")))
+				.andExpect(status().isOk())
+				.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_HTML))
+				.andReturn();
+		// @formatter:on
+
+		String responseHtml = mvcResult.getResponse().getContentAsString();
+		assertThat(responseHtml).contains("Consent required");
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		assertThat(updatedAuthorization.getPrincipalName()).isEqualTo("user");
+		assertThat(updatedAuthorization).isNotNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(false);
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenDeviceAuthorizationConsentRequestUnauthenticatedThenBadRequest() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName("user")
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt))
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.attribute(OAuth2ParameterNames.STATE, STATE)
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE, registeredClient.getScopes().iterator().next());
+		parameters.set(OAuth2ParameterNames.STATE, STATE);
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI)
+				.params(parameters))
+				.andExpect(status().isBadRequest());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenDeviceAuthorizationConsentRequestValidThenRedirectsToSuccessPage() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName("user")
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt))
+				.attribute(OAuth2ParameterNames.SCOPE, registeredClient.getScopes())
+				.attribute(OAuth2ParameterNames.STATE, STATE)
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.USER_CODE, USER_CODE);
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE, registeredClient.getScopes().iterator().next());
+		parameters.set(OAuth2ParameterNames.STATE, STATE);
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(DEFAULT_DEVICE_VERIFICATION_ENDPOINT_URI)
+				.params(parameters)
+				.with(user("user")))
+				.andExpect(status().is3xxRedirection())
+				.andReturn();
+		// @formatter:on
+
+		assertThat(mvcResult.getResponse().getHeader(HttpHeaders.LOCATION)).isEqualTo("/?success");
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		assertThat(updatedAuthorization).isNotNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2UserCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestUnauthenticatedThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(registeredClient.getClientId())
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt), withInvalidated())
+				.authorizedScopes(registeredClient.getScopes())
+				.attribute(Principal.class.getName(), new UsernamePasswordAuthenticationToken("user", null))
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(parameters))
+				.andExpect(status().isUnauthorized());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestValidThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(registeredClient.getClientId())
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt), withInvalidated())
+				.authorizedScopes(registeredClient.getScopes())
+				.attribute(Principal.class.getName(), new UsernamePasswordAuthenticationToken("user", null))
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getClientId(), "user")
+						.scope(registeredClient.getScopes().iterator().next())
+						.build();
+		// @formatter:on
+		this.authorizationConsentService.save(authorizationConsent);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(parameters)
+				.headers(withClientAuth(registeredClient)))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+				.andExpect(jsonPath("$.expires_in").isNumber())
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").isNotEmpty())
+				.andReturn();
+		// @formatter:on
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		assertThat(updatedAuthorization).isNotNull();
+		assertThat(updatedAuthorization.getAccessToken()).isNotNull();
+		assertThat(updatedAuthorization.getRefreshToken()).isNotNull();
+		// @formatter:off
+		assertThat(updatedAuthorization.getToken(OAuth2DeviceCode.class))
+				.extracting(isInvalidated())
+				.isEqualTo(true);
+		// @formatter:on
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.OK);
+		OAuth2AccessTokenResponse accessTokenResponse = accessTokenResponseHttpMessageConverter
+			.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		String accessToken = accessTokenResponse.getAccessToken().getTokenValue();
+		OAuth2Authorization accessTokenAuthorization = this.authorizationService.findByToken(accessToken,
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(accessTokenAuthorization).isEqualTo(updatedAuthorization);
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(registeredClient);
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		// @formatter:off
+		OAuth2Authorization authorization = OAuth2Authorization.withRegisteredClient(registeredClient)
+				.principalName(registeredClient.getClientId())
+				.authorizationGrantType(AuthorizationGrantType.DEVICE_CODE)
+				.token(new OAuth2DeviceCode(DEVICE_CODE, issuedAt, expiresAt))
+				.token(new OAuth2UserCode(USER_CODE, issuedAt, expiresAt), withInvalidated())
+				.authorizedScopes(registeredClient.getScopes())
+				.attribute(Principal.class.getName(), new UsernamePasswordAuthenticationToken("user", null))
+				.build();
+		// @formatter:on
+		this.authorizationService.save(authorization);
+
+		// @formatter:off
+		OAuth2AuthorizationConsent authorizationConsent =
+				OAuth2AuthorizationConsent.withId(registeredClient.getClientId(), "user")
+						.scope(registeredClient.getScopes().iterator().next())
+						.build();
+		// @formatter:on
+		this.authorizationConsentService.save(authorizationConsent);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.DEVICE_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.DEVICE_CODE, DEVICE_CODE);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+						.params(parameters)
+						.headers(withClientAuth(registeredClient))
+						.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+		// @formatter:on
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+		String jwkThumbprintClaim = (String) cnfClaims.get("jkt");
+		assertThat(jwkThumbprintClaim).isEqualTo(TestJwks.DEFAULT_EC_JWK.toPublicJWK().computeThumbprint().toString());
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+	private static HttpHeaders withClientAuth(RegisteredClient registeredClient) {
+		HttpHeaders headers = new HttpHeaders();
+		headers.setBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret());
+		return headers;
+	}
+
+	private static Consumer<Map<String, Object>> withInvalidated() {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			return new JdbcRegisteredClientRepository(jdbcOperations);
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithMultipleIssuersAllowed extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+}

+ 645 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2RefreshTokenGrantTests.java

@@ -0,0 +1,645 @@
+/*
+ * Copyright 2020-2025 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.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.Principal;
+import java.security.PublicKey;
+import java.time.Instant;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import jakarta.servlet.http.HttpServletRequest;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.lang.Nullable;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.Transient;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+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.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.TestKeys;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+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.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.Assert;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OAuth 2.0 Refresh Token Grant.
+ *
+ * @author Alexey Nesterov
+ * @since 0.0.3
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2RefreshTokenGrantTests {
+
+	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+
+	private static final String DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI = "/oauth2/revoke";
+
+	private static final String AUTHORITIES_CLAIM = "authorities";
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static NimbusJwtDecoder jwtDecoder;
+
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
+	private static HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		jwtDecoder = NimbusJwtDecoder.withPublicKey(TestKeys.DEFAULT_PUBLIC_KEY).build();
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenRefreshTokenRequestValidThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		MvcResult mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andReturn();
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter
+			.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		// Assert user authorities was propagated as claim in JWT
+		Jwt jwt = jwtDecoder.decode(accessTokenResponse.getAccessToken().getTokenValue());
+		List<String> authoritiesClaim = jwt.getClaim(AUTHORITIES_CLAIM);
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+		Set<String> userAuthorities = new HashSet<>();
+		for (GrantedAuthority authority : principal.getAuthorities()) {
+			userAuthorities.add(authority.getAuthority());
+		}
+		assertThat(authoritiesClaim).containsExactlyInAnyOrderElementsOf(userAuthorities);
+	}
+
+	// gh-432
+	@Test
+	public void requestWhenRevokeAndRefreshThenAccessTokenActive() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessToken token = authorization.getAccessToken().getToken();
+		OAuth2TokenType tokenType = OAuth2TokenType.ACCESS_TOKEN;
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI)
+				.params(getTokenRevocationRequestParameters(token, tokenType))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		OAuth2Authorization.Token<OAuth2AccessToken> accessToken = updatedAuthorization.getAccessToken();
+		assertThat(accessToken.isActive()).isTrue();
+	}
+
+	// gh-1430
+	@Test
+	public void requestWhenRefreshTokenRequestWithPublicClientThenReturnAccessTokenResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId()))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty());
+	}
+
+	@Test
+	public void requestWhenRefreshTokenRequestWithPublicClientAndDPoPProofThenReturnDPoPBoundAccessToken()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.DPOP,
+				"dpop-bound-access-token", Instant.now(), Instant.now().plusSeconds(300));
+		Map<String, Object> accessTokenClaims = new HashMap<>();
+		Map<String, Object> cnfClaim = new HashMap<>();
+		cnfClaim.put("jkt", TestJwks.DEFAULT_EC_JWK.toPublicJWK().computeThumbprint().toString());
+		accessTokenClaims.put("cnf", cnfClaim);
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, accessToken, accessTokenClaims)
+			.build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+		String jwkThumbprintClaim = (String) cnfClaims.get("jkt");
+		assertThat(jwkThumbprintClaim).isEqualTo(TestJwks.DEFAULT_EC_JWK.toPublicJWK().computeThumbprint().toString());
+	}
+
+	@Test
+	public void requestWhenRefreshTokenRequestWithPublicClientAndDPoPProofAndAccessTokenNotBoundThenBadRequest()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isBadRequest())
+			.andExpect(jsonPath("$.error").value(OAuth2ErrorCodes.INVALID_DPOP_PROOF))
+			.andExpect(jsonPath("$.error_description").value("jkt claim is missing."));
+	}
+
+	@Test
+	public void requestWhenRefreshTokenRequestWithPublicClientAndDPoPProofAndDifferentPublicKeyThenBadRequest()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithPublicClientAuthentication.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredPublicClient()
+			.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.DPOP,
+				"dpop-bound-access-token", Instant.now(), Instant.now().plusSeconds(300));
+		Map<String, Object> accessTokenClaims = new HashMap<>();
+		// Bind access token to different public key
+		PublicKey publicKey = TestJwks.DEFAULT_RSA_JWK.toPublicKey();
+		Map<String, Object> cnfClaim = new HashMap<>();
+		cnfClaim.put("jkt", computeSHA256(publicKey));
+		accessTokenClaims.put("cnf", cnfClaim);
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, accessToken, accessTokenClaims)
+			.build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.param(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isBadRequest())
+			.andExpect(jsonPath("$.error").value(OAuth2ErrorCodes.INVALID_DPOP_PROOF))
+			.andExpect(jsonPath("$.error_description").value("jwk header is invalid."));
+	}
+
+	@Test
+	public void requestWhenRefreshTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getRefreshTokenRequestParameters(authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret()))
+				.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+
+		authorization = this.authorizationService.findById(authorization.getId());
+		assertThat(authorization.getAccessToken().getClaims()).containsKey("cnf");
+		@SuppressWarnings("unchecked")
+		Map<String, Object> cnfClaims = (Map<String, Object>) authorization.getAccessToken().getClaims().get("cnf");
+		assertThat(cnfClaims).containsKey("jkt");
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+	private static String computeSHA256(PublicKey publicKey) throws Exception {
+		MessageDigest md = MessageDigest.getInstance("SHA-256");
+		byte[] digest = md.digest(publicKey.getEncoded());
+		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+	}
+
+	private static MultiValueMap<String, String> getRefreshTokenRequestParameters(OAuth2Authorization authorization) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue());
+		parameters.set(OAuth2ParameterNames.REFRESH_TOKEN, authorization.getRefreshToken().getToken().getTokenValue());
+		return parameters;
+	}
+
+	private static MultiValueMap<String, String> getTokenRevocationRequestParameters(OAuth2Token token,
+			OAuth2TokenType tokenType) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.TOKEN, token.getTokenValue());
+		parameters.set(OAuth2ParameterNames.TOKEN_TYPE_HINT, tokenType.getValue());
+		return parameters;
+	}
+
+	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + secret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			jdbcRegisteredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			return jdbcRegisteredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
+			return (context) -> {
+				if (AuthorizationGrantType.REFRESH_TOKEN.equals(context.getAuthorizationGrantType())) {
+					Authentication principal = context.getPrincipal();
+					Set<String> authorities = new HashSet<>();
+					for (GrantedAuthority authority : principal.getAuthorities()) {
+						authorities.add(authority.getAuthority());
+					}
+					context.getClaims().claim(AUTHORITIES_CLAIM, authorities);
+				}
+			};
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithPublicClientAuthentication
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(
+				HttpSecurity http, RegisteredClientRepository registeredClientRepository) throws Exception {
+
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.clientAuthentication((clientAuthentication) ->
+											clientAuthentication
+													.authenticationConverter(
+															new PublicClientRefreshTokenAuthenticationConverter())
+													.authenticationProvider(
+															new PublicClientRefreshTokenAuthenticationProvider(registeredClientRepository)))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@Transient
+	private static final class PublicClientRefreshTokenAuthenticationToken extends OAuth2ClientAuthenticationToken {
+
+		private PublicClientRefreshTokenAuthenticationToken(String clientId) {
+			super(clientId, ClientAuthenticationMethod.NONE, null, null);
+		}
+
+		private PublicClientRefreshTokenAuthenticationToken(RegisteredClient registeredClient) {
+			super(registeredClient, ClientAuthenticationMethod.NONE, null);
+		}
+
+	}
+
+	private static final class PublicClientRefreshTokenAuthenticationConverter implements AuthenticationConverter {
+
+		@Nullable
+		@Override
+		public Authentication convert(HttpServletRequest request) {
+			// grant_type (REQUIRED)
+			String grantType = request.getParameter(OAuth2ParameterNames.GRANT_TYPE);
+			if (!AuthorizationGrantType.REFRESH_TOKEN.getValue().equals(grantType)) {
+				return null;
+			}
+
+			// client_id (REQUIRED)
+			String clientId = request.getParameter(OAuth2ParameterNames.CLIENT_ID);
+			if (!StringUtils.hasText(clientId)) {
+				return null;
+			}
+
+			return new PublicClientRefreshTokenAuthenticationToken(clientId);
+		}
+
+	}
+
+	private static final class PublicClientRefreshTokenAuthenticationProvider implements AuthenticationProvider {
+
+		private final RegisteredClientRepository registeredClientRepository;
+
+		private PublicClientRefreshTokenAuthenticationProvider(RegisteredClientRepository registeredClientRepository) {
+			Assert.notNull(registeredClientRepository, "registeredClientRepository cannot be null");
+			this.registeredClientRepository = registeredClientRepository;
+		}
+
+		@Override
+		public Authentication authenticate(Authentication authentication) throws AuthenticationException {
+			PublicClientRefreshTokenAuthenticationToken publicClientAuthentication = (PublicClientRefreshTokenAuthenticationToken) authentication;
+
+			if (!ClientAuthenticationMethod.NONE.equals(publicClientAuthentication.getClientAuthenticationMethod())) {
+				return null;
+			}
+
+			String clientId = publicClientAuthentication.getPrincipal().toString();
+			RegisteredClient registeredClient = this.registeredClientRepository.findByClientId(clientId);
+			if (registeredClient == null) {
+				throwInvalidClient(OAuth2ParameterNames.CLIENT_ID);
+			}
+
+			if (!registeredClient.getClientAuthenticationMethods()
+				.contains(publicClientAuthentication.getClientAuthenticationMethod())) {
+				throwInvalidClient("authentication_method");
+			}
+
+			return new PublicClientRefreshTokenAuthenticationToken(registeredClient);
+		}
+
+		@Override
+		public boolean supports(Class<?> authentication) {
+			return PublicClientRefreshTokenAuthenticationToken.class.isAssignableFrom(authentication);
+		}
+
+		private static void throwInvalidClient(String parameterName) {
+			OAuth2Error error = new OAuth2Error(OAuth2ErrorCodes.INVALID_CLIENT,
+					"Public client authentication failed: " + parameterName, null);
+			throw new OAuth2AuthenticationException(error);
+		}
+
+	}
+
+}

+ 471 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenExchangeGrantTests.java

@@ -0,0 +1,471 @@
+/*
+ * Copyright 2020-2025 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.security.Principal;
+import java.time.Instant;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+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.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.userdetails.User;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+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.authentication.OAuth2TokenExchangeCompositeAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimNames;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for OAuth 2.0 Token Exchange Grant.
+ *
+ * @author Steve Riesenberg
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2TokenExchangeGrantTests {
+
+	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+
+	private static final String RESOURCE = "https://mydomain.com/resource";
+
+	private static final String AUDIENCE = "audience";
+
+	private static final String SUBJECT_TOKEN = "EfYu_0jEL";
+
+	private static final String ACTOR_TOKEN = "JlNE_xR1f";
+
+	private static final String ACCESS_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:access_token";
+
+	private static final String JWT_TOKEN_TYPE_VALUE = "urn:ietf:params:oauth:token-type:jwt";
+
+	private static NimbusJwtEncoder dPoPProofJwtEncoder;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	private final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenResponseHttpMessageConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		AuthorizationServerConfiguration.JWK_SOURCE = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		JWKSet clientJwkSet = new JWKSet(TestJwks.DEFAULT_EC_JWK);
+		JWKSource<SecurityContext> clientJwkSource = (jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet);
+		dPoPProofJwtEncoder = new NimbusJwtEncoder(clientJwkSource);
+		// @formatter:off
+		AuthorizationServerConfiguration.DB = new EmbeddedDatabaseBuilder()
+				.generateUniqueName(true)
+				.setType(EmbeddedDatabaseType.HSQL)
+				.setScriptEncoding("UTF-8")
+				.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+				.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-consent-schema.sql")
+				.addScript("org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+				.build();
+		// @formatter:on
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_authorization_consent");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		AuthorizationServerConfiguration.DB.shutdown();
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestNotAuthenticatedThenUnauthorized() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(parameters))
+				.andExpect(status().isUnauthorized());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestValidAndNoActorTokenThenReturnAccessTokenResponseForImpersonation()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		UsernamePasswordAuthenticationToken userPrincipal = createUserPrincipal("user");
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.attribute(Principal.class.getName(), userPrincipal)
+			.build();
+		this.authorizationService.save(subjectAuthorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN,
+				subjectAuthorization.getAccessToken().getToken().getTokenValue());
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.RESOURCE, RESOURCE);
+		parameters.set(OAuth2ParameterNames.AUDIENCE, AUDIENCE);
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(parameters)
+				.headers(withClientAuth(registeredClient)))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.refresh_token").doesNotExist())
+				.andExpect(jsonPath("$.expires_in").isNumber())
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").isNotEmpty())
+				.andExpect(jsonPath("$.issued_token_type").isNotEmpty())
+				.andReturn();
+		// @formatter:on
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.OK);
+		OAuth2AccessTokenResponse accessTokenResponse = this.accessTokenResponseHttpMessageConverter
+			.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		String accessToken = accessTokenResponse.getAccessToken().getTokenValue();
+		OAuth2Authorization authorization = this.authorizationService.findByToken(accessToken,
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(authorization).isNotNull();
+		assertThat(authorization.getAccessToken()).isNotNull();
+		assertThat(authorization.getAccessToken().getClaims()).isNotNull();
+		// We do not populate claims (e.g. `aud`) based on the resource or audience
+		// parameters
+		assertThat(authorization.getAccessToken().getClaims().get(OAuth2TokenClaimNames.AUD))
+			.isEqualTo(List.of(registeredClient.getClientId()));
+		assertThat(authorization.getRefreshToken()).isNull();
+		assertThat(authorization.<Authentication>getAttribute(Principal.class.getName())).isEqualTo(userPrincipal);
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestValidAndActorTokenThenReturnAccessTokenResponseForDelegation()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		UsernamePasswordAuthenticationToken userPrincipal = createUserPrincipal("user");
+		UsernamePasswordAuthenticationToken adminPrincipal = createUserPrincipal("admin");
+		Map<String, Object> actorTokenClaims = new HashMap<>();
+		actorTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer2");
+		actorTokenClaims.put(OAuth2TokenClaimNames.SUB, "admin");
+		Map<String, Object> subjectTokenClaims = new HashMap<>();
+		subjectTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer1");
+		subjectTokenClaims.put(OAuth2TokenClaimNames.SUB, "user");
+		subjectTokenClaims.put("may_act", actorTokenClaims);
+		OAuth2AccessToken subjectToken = createAccessToken(SUBJECT_TOKEN);
+		OAuth2AccessToken actorToken = createAccessToken(ACTOR_TOKEN);
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient, subjectToken, subjectTokenClaims)
+				.id(UUID.randomUUID().toString())
+				.attribute(Principal.class.getName(), userPrincipal)
+				.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient, actorToken, actorTokenClaims)
+				.id(UUID.randomUUID().toString())
+				.attribute(Principal.class.getName(), adminPrincipal)
+				.build();
+		// @formatter:on
+		this.authorizationService.save(subjectAuthorization);
+		this.authorizationService.save(actorAuthorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN);
+		parameters.set(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(parameters)
+				.headers(withClientAuth(registeredClient)))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.access_token").isNotEmpty())
+				.andExpect(jsonPath("$.refresh_token").doesNotExist())
+				.andExpect(jsonPath("$.expires_in").isNumber())
+				.andExpect(jsonPath("$.scope").isNotEmpty())
+				.andExpect(jsonPath("$.token_type").isNotEmpty())
+				.andExpect(jsonPath("$.issued_token_type").isNotEmpty())
+				.andReturn();
+		// @formatter:on
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.OK);
+		OAuth2AccessTokenResponse accessTokenResponse = this.accessTokenResponseHttpMessageConverter
+			.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		String accessToken = accessTokenResponse.getAccessToken().getTokenValue();
+		OAuth2Authorization authorization = this.authorizationService.findByToken(accessToken,
+				OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(authorization).isNotNull();
+		assertThat(authorization.getAccessToken()).isNotNull();
+		assertThat(authorization.getAccessToken().getClaims()).isNotNull();
+		assertThat(authorization.getAccessToken().getClaims().get("act")).isNotNull();
+		assertThat(authorization.getRefreshToken()).isNull();
+		assertThat(authorization.<Authentication>getAttribute(Principal.class.getName()))
+			.isInstanceOf(OAuth2TokenExchangeCompositeAuthenticationToken.class);
+	}
+
+	@Test
+	public void requestWhenAccessTokenRequestWithDPoPProofThenReturnDPoPBoundAccessToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.authorizationGrantType(AuthorizationGrantType.TOKEN_EXCHANGE)
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		UsernamePasswordAuthenticationToken userPrincipal = createUserPrincipal("user");
+		UsernamePasswordAuthenticationToken adminPrincipal = createUserPrincipal("admin");
+		Map<String, Object> actorTokenClaims = new HashMap<>();
+		actorTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer2");
+		actorTokenClaims.put(OAuth2TokenClaimNames.SUB, "admin");
+		Map<String, Object> subjectTokenClaims = new HashMap<>();
+		subjectTokenClaims.put(OAuth2TokenClaimNames.ISS, "issuer1");
+		subjectTokenClaims.put(OAuth2TokenClaimNames.SUB, "user");
+		subjectTokenClaims.put("may_act", actorTokenClaims);
+		OAuth2AccessToken subjectToken = createAccessToken(SUBJECT_TOKEN);
+		OAuth2AccessToken actorToken = createAccessToken(ACTOR_TOKEN);
+		// @formatter:off
+		OAuth2Authorization subjectAuthorization = TestOAuth2Authorizations.authorization(registeredClient, subjectToken, subjectTokenClaims)
+				.id(UUID.randomUUID().toString())
+				.attribute(Principal.class.getName(), userPrincipal)
+				.build();
+		OAuth2Authorization actorAuthorization = TestOAuth2Authorizations.authorization(registeredClient, actorToken, actorTokenClaims)
+				.id(UUID.randomUUID().toString())
+				.attribute(Principal.class.getName(), adminPrincipal)
+				.build();
+		// @formatter:on
+		this.authorizationService.save(subjectAuthorization);
+		this.authorizationService.save(actorAuthorization);
+
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.TOKEN_EXCHANGE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.REQUESTED_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN, SUBJECT_TOKEN);
+		parameters.set(OAuth2ParameterNames.SUBJECT_TOKEN_TYPE, JWT_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.ACTOR_TOKEN, ACTOR_TOKEN);
+		parameters.set(OAuth2ParameterNames.ACTOR_TOKEN_TYPE, ACCESS_TOKEN_TYPE_VALUE);
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+
+		String tokenEndpointUri = "http://localhost" + DEFAULT_TOKEN_ENDPOINT_URI;
+		String dPoPProof = generateDPoPProof(tokenEndpointUri);
+
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+						.params(parameters)
+						.headers(withClientAuth(registeredClient))
+						.header(OAuth2AccessToken.TokenType.DPOP.getValue(), dPoPProof))
+				.andExpect(status().isOk())
+				.andExpect(jsonPath("$.token_type").value(OAuth2AccessToken.TokenType.DPOP.getValue()));
+		// @formatter:on
+	}
+
+	private static OAuth2AccessToken createAccessToken(String tokenValue) {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plusSeconds(300);
+		return new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, issuedAt, expiresAt);
+	}
+
+	private static UsernamePasswordAuthenticationToken createUserPrincipal(String username) {
+		User user = new User(username, "", AuthorityUtils.createAuthorityList("ROLE_USER"));
+		return UsernamePasswordAuthenticationToken.authenticated(user, null, user.getAuthorities());
+	}
+
+	private static HttpHeaders withClientAuth(RegisteredClient registeredClient) {
+		HttpHeaders headers = new HttpHeaders();
+		headers.setBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret());
+		return headers;
+	}
+
+	private static Consumer<Map<String, Object>> withInvalidated() {
+		return (metadata) -> metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+	}
+
+	private static Function<OAuth2Authorization.Token<? extends OAuth2Token>, Boolean> isInvalidated() {
+		return (token) -> token.getMetadata(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME);
+	}
+
+	private static String generateDPoPProof(String tokenEndpointUri) {
+		// @formatter:off
+		Map<String, Object> publicJwk = TestJwks.DEFAULT_EC_JWK
+				.toPublicJWK()
+				.toJSONObject();
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.ES256)
+				.type("dpop+jwt")
+				.jwk(publicJwk)
+				.build();
+		JwtClaimsSet claims = JwtClaimsSet.builder()
+				.issuedAt(Instant.now())
+				.claim("htm", "POST")
+				.claim("htu", tokenEndpointUri)
+				.id(UUID.randomUUID().toString())
+				.build();
+		// @formatter:on
+		Jwt jwt = dPoPProofJwtEncoder.encode(JwtEncoderParameters.from(jwsHeader, claims));
+		return jwt.getTokenValue();
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		static JWKSource<SecurityContext> JWK_SOURCE;
+
+		static EmbeddedDatabase DB;
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			return new JdbcRegisteredClientRepository(jdbcOperations);
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(DB);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return JWK_SOURCE;
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+	}
+
+}

+ 609 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenIntrospectionTests.java

@@ -0,0 +1,609 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+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.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.OAuth2TokenIntrospectionClaimNames;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenIntrospection;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenIntrospectionAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.http.converter.OAuth2TokenIntrospectionHttpMessageConverter;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenClaimsSet;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenIntrospectionAuthenticationConverter;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OAuth 2.0 Token Introspection endpoint.
+ *
+ * @author Gerardo Roza
+ * @author Joe Grandja
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2TokenIntrospectionTests {
+
+	private static EmbeddedDatabase db;
+
+	private static OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer;
+
+	private static AuthenticationConverter authenticationConverter;
+
+	private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
+
+	private static AuthenticationProvider authenticationProvider;
+
+	private static Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer;
+
+	private static AuthenticationSuccessHandler authenticationSuccessHandler;
+
+	private static AuthenticationFailureHandler authenticationFailureHandler;
+
+	private static final HttpMessageConverter<OAuth2TokenIntrospection> tokenIntrospectionHttpResponseConverter = new OAuth2TokenIntrospectionHttpMessageConverter();
+
+	private static final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@Autowired
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	@BeforeAll
+	public static void init() {
+		authenticationConverter = mock(AuthenticationConverter.class);
+		authenticationConvertersConsumer = mock(Consumer.class);
+		authenticationProvider = mock(AuthenticationProvider.class);
+		authenticationProvidersConsumer = mock(Consumer.class);
+		authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		accessTokenCustomizer = mock(OAuth2TokenCustomizer.class);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@SuppressWarnings("unchecked")
+	@BeforeEach
+	public void setup() {
+		reset(authenticationConverter);
+		reset(authenticationConvertersConsumer);
+		reset(authenticationProvider);
+		reset(authenticationProvidersConsumer);
+		reset(authenticationSuccessHandler);
+		reset(authenticationFailureHandler);
+		reset(accessTokenCustomizer);
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenIntrospectValidAccessTokenThenActive() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient introspectRegisteredClient = TestRegisteredClients.registeredClient2()
+			.clientSecret("secret-2")
+			.build();
+		this.registeredClientRepository.save(introspectRegisteredClient);
+
+		RegisteredClient authorizedRegisteredClient = TestRegisteredClients.registeredClient().build();
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(Duration.ofHours(1));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				issuedAt, expiresAt, new HashSet<>(Arrays.asList("scope1", "scope2")));
+		// @formatter:off
+		OAuth2TokenClaimsSet accessTokenClaims = OAuth2TokenClaimsSet.builder()
+				.issuer("https://provider.com")
+				.subject("subject")
+				.audience(Collections.singletonList(authorizedRegisteredClient.getClientId()))
+				.issuedAt(issuedAt)
+				.notBefore(issuedAt)
+				.expiresAt(expiresAt)
+				.claim(OAuth2TokenIntrospectionClaimNames.SCOPE, accessToken.getScopes())
+				.id("id")
+				.build();
+		// @formatter:on
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(authorizedRegisteredClient, accessToken, accessTokenClaims.getClaims())
+			.build();
+		this.registeredClientRepository.save(authorizedRegisteredClient);
+		this.authorizationService.save(authorization);
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(this.authorizationServerSettings.getTokenIntrospectionEndpoint())
+				.params(getTokenIntrospectionRequestParameters(accessToken, OAuth2TokenType.ACCESS_TOKEN))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(introspectRegisteredClient)))
+				.andExpect(status().isOk())
+				.andReturn();
+		// @formatter:on
+
+		OAuth2TokenIntrospection tokenIntrospectionResponse = readTokenIntrospectionResponse(mvcResult);
+		assertThat(tokenIntrospectionResponse.isActive()).isTrue();
+		assertThat(tokenIntrospectionResponse.getClientId()).isEqualTo(authorizedRegisteredClient.getClientId());
+		assertThat(tokenIntrospectionResponse.getUsername()).isNull();
+		assertThat(tokenIntrospectionResponse.getIssuedAt()).isBetween(accessTokenClaims.getIssuedAt().minusSeconds(1),
+				accessTokenClaims.getIssuedAt().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getExpiresAt()).isBetween(
+				accessTokenClaims.getExpiresAt().minusSeconds(1), accessTokenClaims.getExpiresAt().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getScopes()).containsExactlyInAnyOrderElementsOf(accessToken.getScopes());
+		assertThat(tokenIntrospectionResponse.getTokenType()).isEqualTo(accessToken.getTokenType().getValue());
+		assertThat(tokenIntrospectionResponse.getNotBefore()).isBetween(
+				accessTokenClaims.getNotBefore().minusSeconds(1), accessTokenClaims.getNotBefore().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getSubject()).isEqualTo(accessTokenClaims.getSubject());
+		assertThat(tokenIntrospectionResponse.getAudience())
+			.containsExactlyInAnyOrderElementsOf(accessTokenClaims.getAudience());
+		assertThat(tokenIntrospectionResponse.getIssuer()).isEqualTo(accessTokenClaims.getIssuer());
+		assertThat(tokenIntrospectionResponse.getId()).isEqualTo(accessTokenClaims.getId());
+	}
+
+	@Test
+	public void requestWhenIntrospectValidRefreshTokenThenActive() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient introspectRegisteredClient = TestRegisteredClients.registeredClient2()
+			.clientSecret("secret-2")
+			.build();
+		this.registeredClientRepository.save(introspectRegisteredClient);
+
+		RegisteredClient authorizedRegisteredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(authorizedRegisteredClient).build();
+		OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
+		this.registeredClientRepository.save(authorizedRegisteredClient);
+		this.authorizationService.save(authorization);
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(this.authorizationServerSettings.getTokenIntrospectionEndpoint())
+				.params(getTokenIntrospectionRequestParameters(refreshToken, OAuth2TokenType.REFRESH_TOKEN))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(introspectRegisteredClient)))
+				.andExpect(status().isOk())
+				.andReturn();
+		// @formatter:on
+
+		OAuth2TokenIntrospection tokenIntrospectionResponse = readTokenIntrospectionResponse(mvcResult);
+		assertThat(tokenIntrospectionResponse.isActive()).isTrue();
+		assertThat(tokenIntrospectionResponse.getClientId()).isEqualTo(authorizedRegisteredClient.getClientId());
+		assertThat(tokenIntrospectionResponse.getUsername()).isNull();
+		assertThat(tokenIntrospectionResponse.getIssuedAt()).isBetween(refreshToken.getIssuedAt().minusSeconds(1),
+				refreshToken.getIssuedAt().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getExpiresAt()).isBetween(refreshToken.getExpiresAt().minusSeconds(1),
+				refreshToken.getExpiresAt().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getScopes()).isNull();
+		assertThat(tokenIntrospectionResponse.getTokenType()).isNull();
+		assertThat(tokenIntrospectionResponse.getNotBefore()).isNull();
+		assertThat(tokenIntrospectionResponse.getSubject()).isNull();
+		assertThat(tokenIntrospectionResponse.getAudience()).isNull();
+		assertThat(tokenIntrospectionResponse.getIssuer()).isNull();
+		assertThat(tokenIntrospectionResponse.getId()).isNull();
+	}
+
+	@Test
+	public void requestWhenObtainReferenceAccessTokenAndIntrospectThenActive() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		TokenSettings tokenSettings = TokenSettings.builder()
+				.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
+				.build();
+		RegisteredClient authorizedRegisteredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(tokenSettings)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(authorizedRegisteredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(authorizedRegisteredClient).build();
+		this.authorizationService.save(authorization);
+
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(post(this.authorizationServerSettings.getTokenEndpoint())
+				.params(getAuthorizationCodeTokenRequestParameters(authorizedRegisteredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(authorizedRegisteredClient)))
+				.andExpect(status().isOk())
+				.andReturn();
+		// @formatter:on
+
+		OAuth2AccessTokenResponse accessTokenResponse = readAccessTokenResponse(mvcResult);
+		OAuth2AccessToken accessToken = accessTokenResponse.getAccessToken();
+
+		RegisteredClient introspectRegisteredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(introspectRegisteredClient);
+
+		// @formatter:off
+		mvcResult = this.mvc.perform(post(this.authorizationServerSettings.getTokenIntrospectionEndpoint())
+				.params(getTokenIntrospectionRequestParameters(accessToken, OAuth2TokenType.ACCESS_TOKEN))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(introspectRegisteredClient)))
+				.andExpect(status().isOk())
+				.andReturn();
+		// @formatter:on
+
+		OAuth2TokenIntrospection tokenIntrospectionResponse = readTokenIntrospectionResponse(mvcResult);
+
+		ArgumentCaptor<OAuth2TokenClaimsContext> accessTokenClaimsContextCaptor = ArgumentCaptor
+			.forClass(OAuth2TokenClaimsContext.class);
+		verify(accessTokenCustomizer).customize(accessTokenClaimsContextCaptor.capture());
+
+		OAuth2TokenClaimsContext accessTokenClaimsContext = accessTokenClaimsContextCaptor.getValue();
+		OAuth2TokenClaimsSet accessTokenClaims = accessTokenClaimsContext.getClaims().build();
+
+		assertThat(tokenIntrospectionResponse.isActive()).isTrue();
+		assertThat(tokenIntrospectionResponse.getClientId()).isEqualTo(authorizedRegisteredClient.getClientId());
+		assertThat(tokenIntrospectionResponse.getUsername()).isNull();
+		assertThat(tokenIntrospectionResponse.getIssuedAt()).isBetween(accessTokenClaims.getIssuedAt().minusSeconds(1),
+				accessTokenClaims.getIssuedAt().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getExpiresAt()).isBetween(
+				accessTokenClaims.getExpiresAt().minusSeconds(1), accessTokenClaims.getExpiresAt().plusSeconds(1));
+		List<String> scopes = new ArrayList<>(accessTokenClaims.getClaim(OAuth2ParameterNames.SCOPE));
+		assertThat(tokenIntrospectionResponse.getScopes()).containsExactlyInAnyOrderElementsOf(scopes);
+		assertThat(tokenIntrospectionResponse.getTokenType()).isEqualTo(accessToken.getTokenType().getValue());
+		assertThat(tokenIntrospectionResponse.getNotBefore()).isBetween(
+				accessTokenClaims.getNotBefore().minusSeconds(1), accessTokenClaims.getNotBefore().plusSeconds(1));
+		assertThat(tokenIntrospectionResponse.getSubject()).isEqualTo(accessTokenClaims.getSubject());
+		assertThat(tokenIntrospectionResponse.getAudience())
+			.containsExactlyInAnyOrderElementsOf(accessTokenClaims.getAudience());
+		assertThat(tokenIntrospectionResponse.getIssuer()).isEqualTo(accessTokenClaims.getIssuer());
+		assertThat(tokenIntrospectionResponse.getId()).isEqualTo(accessTokenClaims.getId());
+	}
+
+	@Test
+	public void requestWhenTokenIntrospectionEndpointCustomizedThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomTokenIntrospectionEndpoint.class).autowire();
+
+		RegisteredClient introspectRegisteredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(introspectRegisteredClient);
+
+		RegisteredClient authorizedRegisteredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(authorizedRegisteredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(authorizedRegisteredClient).build();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+
+		Authentication clientPrincipal = new OAuth2ClientAuthenticationToken(introspectRegisteredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, introspectRegisteredClient.getClientSecret());
+		OAuth2TokenIntrospectionAuthenticationToken tokenIntrospectionAuthentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, null, null);
+
+		given(authenticationConverter.convert(any())).willReturn(tokenIntrospectionAuthentication);
+		given(authenticationProvider.supports(eq(OAuth2TokenIntrospectionAuthenticationToken.class))).willReturn(true);
+		given(authenticationProvider.authenticate(any())).willReturn(tokenIntrospectionAuthentication);
+
+		// @formatter:off
+		this.mvc.perform(post(this.authorizationServerSettings.getTokenIntrospectionEndpoint())
+				.params(getTokenIntrospectionRequestParameters(accessToken, OAuth2TokenType.ACCESS_TOKEN))
+				.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(introspectRegisteredClient)))
+				.andExpect(status().isOk());
+		// @formatter:on
+
+		verify(authenticationConverter).convert(any());
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).allMatch((converter) -> converter == authenticationConverter
+				|| converter instanceof OAuth2TokenIntrospectionAuthenticationConverter);
+
+		verify(authenticationProvider).authenticate(eq(tokenIntrospectionAuthentication));
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders).allMatch((provider) -> provider == authenticationProvider
+				|| provider instanceof OAuth2TokenIntrospectionAuthenticationProvider);
+
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(),
+				eq(tokenIntrospectionAuthentication));
+	}
+
+	@Test
+	public void requestWhenIntrospectionRequestIncludesIssuerPathThenActive() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomTokenIntrospectionEndpoint.class).autowire();
+
+		RegisteredClient introspectRegisteredClient = TestRegisteredClients.registeredClient2().build();
+		this.registeredClientRepository.save(introspectRegisteredClient);
+
+		RegisteredClient authorizedRegisteredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(authorizedRegisteredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(authorizedRegisteredClient).build();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+
+		Authentication clientPrincipal = new OAuth2ClientAuthenticationToken(introspectRegisteredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, introspectRegisteredClient.getClientSecret());
+		OAuth2TokenIntrospectionAuthenticationToken tokenIntrospectionAuthentication = new OAuth2TokenIntrospectionAuthenticationToken(
+				accessToken.getTokenValue(), clientPrincipal, null, null);
+
+		given(authenticationConverter.convert(any())).willReturn(tokenIntrospectionAuthentication);
+		given(authenticationProvider.supports(eq(OAuth2TokenIntrospectionAuthenticationToken.class))).willReturn(true);
+		given(authenticationProvider.authenticate(any())).willReturn(tokenIntrospectionAuthentication);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		// @formatter:off
+		this.mvc.perform(post(issuer.concat(this.authorizationServerSettings.getTokenIntrospectionEndpoint()))
+						.params(getTokenIntrospectionRequestParameters(accessToken, OAuth2TokenType.ACCESS_TOKEN))
+						.header(HttpHeaders.AUTHORIZATION, getAuthorizationHeader(introspectRegisteredClient)))
+				.andExpect(status().isOk());
+		// @formatter:on
+	}
+
+	private static MultiValueMap<String, String> getTokenIntrospectionRequestParameters(OAuth2Token token,
+			OAuth2TokenType tokenType) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.TOKEN, token.getTokenValue());
+		parameters.set(OAuth2ParameterNames.TOKEN_TYPE_HINT, tokenType.getValue());
+		return parameters;
+	}
+
+	private static OAuth2TokenIntrospection readTokenIntrospectionResponse(MvcResult mvcResult) throws Exception {
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		return tokenIntrospectionHttpResponseConverter.read(OAuth2TokenIntrospection.class, httpResponse);
+	}
+
+	private static MultiValueMap<String, String> getAuthorizationCodeTokenRequestParameters(
+			RegisteredClient registeredClient, OAuth2Authorization authorization) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.CODE,
+				authorization.getToken(OAuth2AuthorizationCode.class).getToken().getTokenValue());
+		parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
+		return parameters;
+	}
+
+	private static OAuth2AccessTokenResponse readAccessTokenResponse(MvcResult mvcResult) throws Exception {
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		return accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+	}
+
+	private static String getAuthorizationHeader(RegisteredClient registeredClient) throws Exception {
+		String clientId = registeredClient.getClientId();
+		String clientSecret = registeredClient.getClientSecret();
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		clientSecret = URLEncoder.encode(clientSecret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + clientSecret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return "Basic " + new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			jdbcRegisteredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			return jdbcRegisteredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().tokenIntrospectionEndpoint("/test/introspect").build();
+		}
+
+		@Bean
+		OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer() {
+			return accessTokenCustomizer;
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomTokenIntrospectionEndpoint
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.tokenIntrospectionEndpoint((tokenIntrospectionEndpoint) ->
+											tokenIntrospectionEndpoint
+													.introspectionRequestConverter(authenticationConverter)
+													.introspectionRequestConverters(authenticationConvertersConsumer)
+													.authenticationProvider(authenticationProvider)
+													.authenticationProviders(authenticationProvidersConsumer)
+													.introspectionResponseHandler(authenticationSuccessHandler)
+													.errorResponseHandler(authenticationFailureHandler))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		@Override
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder()
+				.multipleIssuersAllowed(true)
+				.tokenIntrospectionEndpoint("/test/introspect")
+				.build();
+		}
+
+	}
+
+}

+ 412 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OAuth2TokenRevocationTests.java

@@ -0,0 +1,412 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.List;
+import java.util.function.Consumer;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.http.HttpHeaders;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.security.authentication.AuthenticationProvider;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+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.jose.TestJwks;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+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.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2TokenRevocationAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2TokenRevocationAuthenticationConverter;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OAuth 2.0 Token Revocation endpoint.
+ *
+ * @author Joe Grandja
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OAuth2TokenRevocationTests {
+
+	private static final String DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI = "/oauth2/revoke";
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static AuthenticationConverter authenticationConverter;
+
+	private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
+
+	private static AuthenticationProvider authenticationProvider;
+
+	private static Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer;
+
+	private static AuthenticationSuccessHandler authenticationSuccessHandler;
+
+	private static AuthenticationFailureHandler authenticationFailureHandler;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		authenticationConverter = mock(AuthenticationConverter.class);
+		authenticationConvertersConsumer = mock(Consumer.class);
+		authenticationProvider = mock(AuthenticationProvider.class);
+		authenticationProvidersConsumer = mock(Consumer.class);
+		authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+	}
+
+	@AfterEach
+	public void tearDown() {
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenRevokeRefreshTokenThenRevoked() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2RefreshToken token = authorization.getRefreshToken().getToken();
+		OAuth2TokenType tokenType = OAuth2TokenType.REFRESH_TOKEN;
+		this.authorizationService.save(authorization);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI)
+				.params(getTokenRevocationRequestParameters(token, tokenType))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = updatedAuthorization.getRefreshToken();
+		assertThat(refreshToken.isInvalidated()).isTrue();
+		OAuth2Authorization.Token<OAuth2AccessToken> accessToken = updatedAuthorization.getAccessToken();
+		assertThat(accessToken.isInvalidated()).isTrue();
+	}
+
+	@Test
+	public void requestWhenRevokeAccessTokenThenRevoked() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2AccessToken token = authorization.getAccessToken().getToken();
+		OAuth2TokenType tokenType = OAuth2TokenType.ACCESS_TOKEN;
+		this.authorizationService.save(authorization);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI)
+				.params(getTokenRevocationRequestParameters(token, tokenType))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		OAuth2Authorization.Token<OAuth2AccessToken> accessToken = updatedAuthorization.getAccessToken();
+		assertThat(accessToken.isInvalidated()).isTrue();
+		OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = updatedAuthorization.getRefreshToken();
+		assertThat(refreshToken.isInvalidated()).isFalse();
+	}
+
+	@Test
+	public void requestWhenRevokeAccessTokenAndRequestIncludesIssuerPathThenRevoked() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2AccessToken token = authorization.getAccessToken().getToken();
+		OAuth2TokenType tokenType = OAuth2TokenType.ACCESS_TOKEN;
+		this.authorizationService.save(authorization);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		// @formatter:off
+		this.mvc.perform(post(issuer.concat(DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI))
+						.params(getTokenRevocationRequestParameters(token, tokenType))
+						.header(HttpHeaders.AUTHORIZATION, "Basic " + encodeBasicAuth(
+								registeredClient.getClientId(), registeredClient.getClientSecret())))
+				.andExpect(status().isOk());
+		// @formatter:on
+
+		OAuth2Authorization updatedAuthorization = this.authorizationService.findById(authorization.getId());
+		OAuth2Authorization.Token<OAuth2AccessToken> accessToken = updatedAuthorization.getAccessToken();
+		assertThat(accessToken.isInvalidated()).isTrue();
+		OAuth2Authorization.Token<OAuth2RefreshToken> refreshToken = updatedAuthorization.getRefreshToken();
+		assertThat(refreshToken.isInvalidated()).isFalse();
+	}
+
+	@Test
+	public void requestWhenTokenRevocationEndpointCustomizedThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationCustomTokenRevocationEndpoint.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		OAuth2AccessToken token = authorization.getAccessToken().getToken();
+		OAuth2TokenType tokenType = OAuth2TokenType.ACCESS_TOKEN;
+		this.authorizationService.save(authorization);
+
+		Authentication clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2TokenRevocationAuthenticationToken tokenRevocationAuthentication = new OAuth2TokenRevocationAuthenticationToken(
+				token, clientPrincipal);
+
+		given(authenticationConverter.convert(any())).willReturn(tokenRevocationAuthentication);
+		given(authenticationProvider.supports(eq(OAuth2TokenRevocationAuthenticationToken.class))).willReturn(true);
+		given(authenticationProvider.authenticate(any())).willReturn(tokenRevocationAuthentication);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_REVOCATION_ENDPOINT_URI)
+				.params(getTokenRevocationRequestParameters(token, tokenType))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		verify(authenticationConverter).convert(any());
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).allMatch((converter) -> converter == authenticationConverter
+				|| converter instanceof OAuth2TokenRevocationAuthenticationConverter);
+
+		verify(authenticationProvider).authenticate(eq(tokenRevocationAuthentication));
+
+		@SuppressWarnings("unchecked")
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders).allMatch((provider) -> provider == authenticationProvider
+				|| provider instanceof OAuth2TokenRevocationAuthenticationProvider);
+
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), eq(tokenRevocationAuthentication));
+	}
+
+	private static MultiValueMap<String, String> getTokenRevocationRequestParameters(OAuth2Token token,
+			OAuth2TokenType tokenType) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.TOKEN, token.getTokenValue());
+		parameters.set(OAuth2ParameterNames.TOKEN_TYPE_HINT, tokenType.getValue());
+		return parameters;
+	}
+
+	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + secret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			jdbcRegisteredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			return jdbcRegisteredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationCustomTokenRevocationEndpoint
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.tokenRevocationEndpoint((tokenRevocationEndpoint) ->
+											tokenRevocationEndpoint
+													.revocationRequestConverter(authenticationConverter)
+													.revocationRequestConverters(authenticationConvertersConsumer)
+													.authenticationProvider(authenticationProvider)
+													.authenticationProviders(authenticationProvidersConsumer)
+													.revocationResponseHandler(authenticationSuccessHandler)
+													.errorResponseHandler(authenticationFailureHandler))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Import(OAuth2AuthorizationServerConfiguration.class)
+	static class AuthorizationServerConfigurationWithMultipleIssuersAllowed extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+}

+ 918 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcClientRegistrationTests.java

@@ -0,0 +1,918 @@
+/*
+ * Copyright 2020-2025 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.time.Duration;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Consumer;
+
+import javax.crypto.spec.SecretKeySpec;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import jakarta.servlet.http.HttpServletResponse;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.assertj.core.data.TemporalUnitWithinOffset;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.http.server.ServletServerHttpResponse;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationProvider;
+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.crypto.factory.PasswordEncoderFactories;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientConfigurationAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.oidc.converter.OidcClientRegistrationRegisteredClientConverter;
+import org.springframework.security.oauth2.server.authorization.oidc.converter.RegisteredClientOidcClientRegistrationConverter;
+import org.springframework.security.oauth2.server.authorization.oidc.http.converter.OidcClientRegistrationHttpMessageConverter;
+import org.springframework.security.oauth2.server.authorization.oidc.web.authentication.OidcClientRegistrationAuthenticationConverter;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.CollectionUtils;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for OpenID Connect Dynamic Client Registration 1.0.
+ *
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ * @author Dmitriy Dubson
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OidcClientRegistrationTests {
+
+	private static final String ISSUER = "https://example.com:8443/issuer1";
+
+	private static final String DEFAULT_TOKEN_ENDPOINT_URI = "/oauth2/token";
+
+	private static final String DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI = "/connect/register";
+
+	private static final HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	private static final HttpMessageConverter<OidcClientRegistration> clientRegistrationHttpMessageConverter = new OidcClientRegistrationHttpMessageConverter();
+
+	private static EmbeddedDatabase db;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static JWKSet clientJwkSet;
+
+	private static JwtEncoder jwtClientAssertionEncoder;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	private static AuthenticationConverter authenticationConverter;
+
+	private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
+
+	private static AuthenticationProvider authenticationProvider;
+
+	private static Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer;
+
+	private static AuthenticationSuccessHandler authenticationSuccessHandler;
+
+	private static AuthenticationFailureHandler authenticationFailureHandler;
+
+	private MockWebServer server;
+
+	private String clientJwkSetUrl;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		clientJwkSet = new JWKSet(TestJwks.generateRsaJwk().build());
+		jwtClientAssertionEncoder = new NimbusJwtEncoder(
+				(jwkSelector, securityContext) -> jwkSelector.select(clientJwkSet));
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+		authenticationConverter = mock(AuthenticationConverter.class);
+		authenticationConvertersConsumer = mock(Consumer.class);
+		authenticationProvider = mock(AuthenticationProvider.class);
+		authenticationProvidersConsumer = mock(Consumer.class);
+		authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+	}
+
+	@BeforeEach
+	public void setup() throws Exception {
+		this.server = new MockWebServer();
+		this.server.start();
+		this.clientJwkSetUrl = this.server.url("/jwks").toString();
+		// @formatter:off
+		MockResponse response = new MockResponse()
+				.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+				.setBody(clientJwkSet.toString());
+		// @formatter:on
+		this.server.enqueue(response);
+		given(authenticationProvider.supports(OidcClientRegistrationAuthenticationToken.class)).willReturn(true);
+	}
+
+	@AfterEach
+	public void tearDown() throws Exception {
+		this.server.shutdown();
+		this.jdbcOperations.update("truncate table oauth2_authorization");
+		this.jdbcOperations.update("truncate table oauth2_registered_client");
+		reset(authenticationConverter);
+		reset(authenticationConvertersConsumer);
+		reset(authenticationProvider);
+		reset(authenticationProvidersConsumer);
+		reset(authenticationSuccessHandler);
+		reset(authenticationFailureHandler);
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenClientRegistrationRequestAuthorizedThenClientRegistrationResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
+
+		assertThat(clientRegistrationResponse.getClientId()).isNotNull();
+		assertThat(clientRegistrationResponse.getClientIdIssuedAt()).isNotNull();
+		assertThat(clientRegistrationResponse.getClientSecret()).isNotNull();
+		assertThat(clientRegistrationResponse.getClientSecretExpiresAt()).isNull();
+		assertThat(clientRegistrationResponse.getClientName()).isEqualTo(clientRegistration.getClientName());
+		assertThat(clientRegistrationResponse.getRedirectUris())
+			.containsExactlyInAnyOrderElementsOf(clientRegistration.getRedirectUris());
+		assertThat(clientRegistrationResponse.getGrantTypes())
+			.containsExactlyInAnyOrderElementsOf(clientRegistration.getGrantTypes());
+		assertThat(clientRegistrationResponse.getResponseTypes())
+			.containsExactly(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistrationResponse.getScopes())
+			.containsExactlyInAnyOrderElementsOf(clientRegistration.getScopes());
+		assertThat(clientRegistrationResponse.getTokenEndpointAuthenticationMethod())
+			.isEqualTo(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(clientRegistrationResponse.getIdTokenSignedResponseAlgorithm())
+			.isEqualTo(SignatureAlgorithm.RS256.getName());
+		assertThat(clientRegistrationResponse.getRegistrationClientUrl()).isNotNull();
+		assertThat(clientRegistrationResponse.getRegistrationAccessToken()).isNotEmpty();
+	}
+
+	@Test
+	public void requestWhenClientConfigurationRequestAuthorizedThenClientRegistrationResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
+
+		HttpHeaders httpHeaders = new HttpHeaders();
+		httpHeaders.setBearerAuth(clientRegistrationResponse.getRegistrationAccessToken());
+
+		MvcResult mvcResult = this.mvc
+			.perform(get(clientRegistrationResponse.getRegistrationClientUrl().toURI()).headers(httpHeaders))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andReturn();
+
+		OidcClientRegistration clientConfigurationResponse = readClientRegistrationResponse(mvcResult.getResponse());
+
+		assertThat(clientConfigurationResponse.getClientId()).isEqualTo(clientRegistrationResponse.getClientId());
+		assertThat(clientConfigurationResponse.getClientIdIssuedAt())
+			.isEqualTo(clientRegistrationResponse.getClientIdIssuedAt());
+		assertThat(clientConfigurationResponse.getClientSecret()).isNotNull();
+		assertThat(clientConfigurationResponse.getClientSecretExpiresAt())
+			.isEqualTo(clientRegistrationResponse.getClientSecretExpiresAt());
+		assertThat(clientConfigurationResponse.getClientName()).isEqualTo(clientRegistrationResponse.getClientName());
+		assertThat(clientConfigurationResponse.getRedirectUris())
+			.containsExactlyInAnyOrderElementsOf(clientRegistrationResponse.getRedirectUris());
+		assertThat(clientConfigurationResponse.getGrantTypes())
+			.containsExactlyInAnyOrderElementsOf(clientRegistrationResponse.getGrantTypes());
+		assertThat(clientConfigurationResponse.getResponseTypes())
+			.containsExactlyInAnyOrderElementsOf(clientRegistrationResponse.getResponseTypes());
+		assertThat(clientConfigurationResponse.getScopes())
+			.containsExactlyInAnyOrderElementsOf(clientRegistrationResponse.getScopes());
+		assertThat(clientConfigurationResponse.getTokenEndpointAuthenticationMethod())
+			.isEqualTo(clientRegistrationResponse.getTokenEndpointAuthenticationMethod());
+		assertThat(clientConfigurationResponse.getIdTokenSignedResponseAlgorithm())
+			.isEqualTo(clientRegistrationResponse.getIdTokenSignedResponseAlgorithm());
+		assertThat(clientConfigurationResponse.getRegistrationClientUrl())
+			.isEqualTo(clientRegistrationResponse.getRegistrationClientUrl());
+		assertThat(clientConfigurationResponse.getRegistrationAccessToken()).isNull();
+	}
+
+	@Test
+	public void requestWhenClientRegistrationEndpointCustomizedThenUsed() throws Exception {
+		this.spring.register(CustomClientRegistrationConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		willAnswer((invocation) -> {
+			HttpServletResponse response = invocation.getArgument(1, HttpServletResponse.class);
+			ServletServerHttpResponse httpResponse = new ServletServerHttpResponse(response);
+			httpResponse.setStatusCode(HttpStatus.CREATED);
+			new OidcClientRegistrationHttpMessageConverter().write(clientRegistration, null, httpResponse);
+			return null;
+		}).given(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), any());
+
+		registerClient(clientRegistration);
+
+		verify(authenticationConverter).convert(any());
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).hasSize(2)
+			.allMatch((converter) -> converter == authenticationConverter
+					|| converter instanceof OidcClientRegistrationAuthenticationConverter);
+
+		verify(authenticationProvider).authenticate(any());
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders).hasSize(3)
+			.allMatch((provider) -> provider == authenticationProvider
+					|| provider instanceof OidcClientRegistrationAuthenticationProvider
+					|| provider instanceof OidcClientConfigurationAuthenticationProvider);
+
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), any());
+		verifyNoInteractions(authenticationFailureHandler);
+	}
+
+	@Test
+	public void requestWhenClientRegistrationEndpointCustomizedWithAuthenticationFailureHandlerThenUsed()
+			throws Exception {
+		this.spring.register(CustomClientRegistrationConfiguration.class).autowire();
+
+		given(authenticationProvider.authenticate(any())).willThrow(new OAuth2AuthenticationException("error"));
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI))
+			.param(OAuth2ParameterNames.CLIENT_ID, "invalid")
+			.with(jwt()));
+
+		verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), any());
+		verifyNoInteractions(authenticationSuccessHandler);
+	}
+
+	// gh-1056
+	@Test
+	public void requestWhenClientRegistersWithSecretThenClientAuthenticationSuccess() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
+
+		this.mvc
+			.perform(post(ISSUER.concat(DEFAULT_TOKEN_ENDPOINT_URI))
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1")
+				.with(httpBasic(clientRegistrationResponse.getClientId(),
+						clientRegistrationResponse.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1"))
+			.andReturn();
+	}
+
+	// gh-1344
+	@Test
+	public void requestWhenClientRegistersWithClientSecretJwtThenClientAuthenticationSuccess() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
+
+		JwsHeader jwsHeader = JwsHeader.with(MacAlgorithm.HS256).build();
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		JwtClaimsSet jwtClaimsSet = JwtClaimsSet.builder()
+			.issuer(clientRegistrationResponse.getClientId())
+			.subject(clientRegistrationResponse.getClientId())
+			.audience(Collections.singletonList(asUrl(ISSUER, this.authorizationServerSettings.getTokenEndpoint())))
+			.issuedAt(issuedAt)
+			.expiresAt(expiresAt)
+			.build();
+
+		JWKSet jwkSet = new JWKSet(
+				TestJwks.jwk(new SecretKeySpec(clientRegistrationResponse.getClientSecret().getBytes(), "HS256"))
+					.build());
+		JwtEncoder jwtClientAssertionEncoder = new NimbusJwtEncoder(
+				(jwkSelector, securityContext) -> jwkSelector.select(jwkSet));
+
+		Jwt jwtAssertion = jwtClientAssertionEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
+
+		this.mvc
+			.perform(post(ISSUER.concat(DEFAULT_TOKEN_ENDPOINT_URI))
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, "scope1")
+				.param(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE,
+						"urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
+				.param(OAuth2ParameterNames.CLIENT_ASSERTION, jwtAssertion.getTokenValue())
+				.param(OAuth2ParameterNames.CLIENT_ID, clientRegistrationResponse.getClientId()))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value("scope1"));
+	}
+
+	@Test
+	public void requestWhenClientRegistersWithCustomMetadataThenSavedToRegisteredClient() throws Exception {
+		this.spring.register(CustomClientMetadataConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.claim("custom-metadata-name-1", "value-1")
+				.claim("custom-metadata-name-2", "value-2")
+				.claim("non-registered-custom-metadata", "value-3")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
+
+		RegisteredClient registeredClient = this.registeredClientRepository
+			.findByClientId(clientRegistrationResponse.getClientId());
+
+		assertThat(clientRegistrationResponse.<String>getClaim("custom-metadata-name-1")).isEqualTo("value-1");
+		assertThat(clientRegistrationResponse.<String>getClaim("custom-metadata-name-2")).isEqualTo("value-2");
+		assertThat(clientRegistrationResponse.<String>getClaim("non-registered-custom-metadata")).isNull();
+
+		assertThat(registeredClient.getClientSettings().<String>getSetting("custom-metadata-name-1"))
+			.isEqualTo("value-1");
+		assertThat(registeredClient.getClientSettings().<String>getSetting("custom-metadata-name-2"))
+			.isEqualTo("value-2");
+		assertThat(registeredClient.getClientSettings().<String>getSetting("non-registered-custom-metadata")).isNull();
+	}
+
+	// gh-2111
+	@Test
+	public void requestWhenClientRegistersWithSecretExpirationThenClientRegistrationResponse() throws Exception {
+		this.spring.register(ClientSecretExpirationConfiguration.class).autowire();
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistration clientRegistrationResponse = registerClient(clientRegistration);
+
+		Instant expectedSecretExpiryDate = Instant.now().plus(Duration.ofHours(24));
+		TemporalUnitWithinOffset allowedDelta = new TemporalUnitWithinOffset(1, ChronoUnit.MINUTES);
+
+		// Returned response contains expiration date
+		assertThat(clientRegistrationResponse.getClientSecretExpiresAt()).isNotNull()
+			.isCloseTo(expectedSecretExpiryDate, allowedDelta);
+
+		RegisteredClient registeredClient = this.registeredClientRepository
+			.findByClientId(clientRegistrationResponse.getClientId());
+
+		// Persisted RegisteredClient contains expiration date
+		assertThat(registeredClient).isNotNull();
+		assertThat(registeredClient.getClientSecretExpiresAt()).isNotNull()
+			.isCloseTo(expectedSecretExpiryDate, allowedDelta);
+	}
+
+	private OidcClientRegistration registerClient(OidcClientRegistration clientRegistration) throws Exception {
+		// ***** (1) Obtain the "initial" access token used for registering the client
+
+		String clientRegistrationScope = "client.create";
+		// @formatter:off
+		RegisteredClient clientRegistrar = RegisteredClient.withId("client-registrar-1")
+				.clientId("client-registrar-1")
+				.clientAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT)
+				.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
+				.scope(clientRegistrationScope)
+				.clientSettings(
+						ClientSettings.builder()
+								.jwkSetUrl(this.clientJwkSetUrl)
+								.tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS256)
+								.build()
+				)
+				.build();
+		// @formatter:on
+		this.registeredClientRepository.save(clientRegistrar);
+
+		// @formatter:off
+		JwsHeader jwsHeader = JwsHeader.with(SignatureAlgorithm.RS256)
+				.build();
+		JwtClaimsSet jwtClaimsSet = jwtClientAssertionClaims(clientRegistrar)
+				.build();
+		// @formatter:on
+		Jwt jwtAssertion = jwtClientAssertionEncoder.encode(JwtEncoderParameters.from(jwsHeader, jwtClaimsSet));
+
+		MvcResult mvcResult = this.mvc
+			.perform(post(ISSUER.concat(DEFAULT_TOKEN_ENDPOINT_URI))
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.param(OAuth2ParameterNames.SCOPE, clientRegistrationScope)
+				.param(OAuth2ParameterNames.CLIENT_ASSERTION_TYPE,
+						"urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
+				.param(OAuth2ParameterNames.CLIENT_ASSERTION, jwtAssertion.getTokenValue())
+				.param(OAuth2ParameterNames.CLIENT_ID, clientRegistrar.getClientId()))
+			.andExpect(status().isOk())
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").value(clientRegistrationScope))
+			.andReturn();
+
+		OAuth2AccessToken accessToken = readAccessTokenResponse(mvcResult.getResponse()).getAccessToken();
+
+		// ***** (2) Register the client
+
+		HttpHeaders httpHeaders = new HttpHeaders();
+		httpHeaders.setBearerAuth(accessToken.getTokenValue());
+
+		// Register the client
+		mvcResult = this.mvc
+			.perform(post(ISSUER.concat(DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI)).headers(httpHeaders)
+				.contentType(MediaType.APPLICATION_JSON)
+				.content(getClientRegistrationRequestContent(clientRegistration)))
+			.andExpect(status().isCreated())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andReturn();
+
+		return readClientRegistrationResponse(mvcResult.getResponse());
+	}
+
+	private JwtClaimsSet.Builder jwtClientAssertionClaims(RegisteredClient registeredClient) {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		return JwtClaimsSet.builder()
+			.issuer(registeredClient.getClientId())
+			.subject(registeredClient.getClientId())
+			.audience(Collections.singletonList(asUrl(ISSUER, this.authorizationServerSettings.getTokenEndpoint())))
+			.issuedAt(issuedAt)
+			.expiresAt(expiresAt);
+	}
+
+	private static String asUrl(String uri, String path) {
+		return UriComponentsBuilder.fromUriString(uri).path(path).build().toUriString();
+	}
+
+	private static OAuth2AccessTokenResponse readAccessTokenResponse(MockHttpServletResponse response)
+			throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+	}
+
+	private static byte[] getClientRegistrationRequestContent(OidcClientRegistration clientRegistration)
+			throws Exception {
+		MockHttpOutputMessage httpRequest = new MockHttpOutputMessage();
+		clientRegistrationHttpMessageConverter.write(clientRegistration, null, httpRequest);
+		return httpRequest.getBodyAsBytes();
+	}
+
+	private static OidcClientRegistration readClientRegistrationResponse(MockHttpServletResponse response)
+			throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, httpResponse);
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class CustomClientRegistrationConfiguration extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		@Override
+		public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc
+													.clientRegistrationEndpoint((clientRegistration) ->
+															clientRegistration
+																	.clientRegistrationRequestConverter(authenticationConverter)
+																	.clientRegistrationRequestConverters(authenticationConvertersConsumer)
+																	.authenticationProvider(authenticationProvider)
+																	.authenticationProviders(authenticationProvidersConsumer)
+																	.clientRegistrationResponseHandler(authenticationSuccessHandler)
+																	.errorResponseHandler(authenticationFailureHandler)
+													)
+									)
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class CustomClientMetadataConfiguration extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		@Override
+		public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc
+													.clientRegistrationEndpoint((clientRegistration) ->
+															clientRegistration
+																	.authenticationProviders(configureClientRegistrationConverters())
+													)
+									)
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		private Consumer<List<AuthenticationProvider>> configureClientRegistrationConverters() {
+			// @formatter:off
+			return (authenticationProviders) ->
+					authenticationProviders.forEach((authenticationProvider) -> {
+						List<String> supportedCustomClientMetadata = List.of("custom-metadata-name-1", "custom-metadata-name-2");
+						if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider provider) {
+							provider.setRegisteredClientConverter(new CustomRegisteredClientConverter(supportedCustomClientMetadata));
+							provider.setClientRegistrationConverter(new CustomClientRegistrationConverter(supportedCustomClientMetadata));
+						}
+					});
+			// @formatter:on
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class ClientSecretExpirationConfiguration extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		@Override
+		public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc
+													.clientRegistrationEndpoint((clientRegistration) ->
+															clientRegistration
+																	.authenticationProviders(configureClientRegistrationConverters())
+													)
+									)
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		private Consumer<List<AuthenticationProvider>> configureClientRegistrationConverters() {
+			// @formatter:off
+			return (authenticationProviders) ->
+					authenticationProviders.forEach((authenticationProvider) -> {
+						if (authenticationProvider instanceof OidcClientRegistrationAuthenticationProvider provider) {
+							provider.setRegisteredClientConverter(new ClientSecretExpirationRegisteredClientConverter());
+						}
+					});
+			// @formatter:on
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc
+													.clientRegistrationEndpoint(Customizer.withDefaults())
+									)
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			JdbcRegisteredClientRepository registeredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			registeredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			registeredClientRepository.save(registeredClient);
+			return registeredClientRepository;
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
+			return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+		}
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return PasswordEncoderFactories.createDelegatingPasswordEncoder();
+		}
+
+	}
+
+	private static final class CustomRegisteredClientConverter
+			implements Converter<OidcClientRegistration, RegisteredClient> {
+
+		private final OidcClientRegistrationRegisteredClientConverter delegate = new OidcClientRegistrationRegisteredClientConverter();
+
+		private final List<String> supportedCustomClientMetadata;
+
+		private CustomRegisteredClientConverter(List<String> supportedCustomClientMetadata) {
+			this.supportedCustomClientMetadata = supportedCustomClientMetadata;
+		}
+
+		@Override
+		public RegisteredClient convert(OidcClientRegistration clientRegistration) {
+			RegisteredClient registeredClient = this.delegate.convert(clientRegistration);
+
+			ClientSettings.Builder clientSettingsBuilder = ClientSettings
+				.withSettings(registeredClient.getClientSettings().getSettings());
+			if (!CollectionUtils.isEmpty(this.supportedCustomClientMetadata)) {
+				clientRegistration.getClaims().forEach((claim, value) -> {
+					if (this.supportedCustomClientMetadata.contains(claim)) {
+						clientSettingsBuilder.setting(claim, value);
+					}
+				});
+			}
+
+			return RegisteredClient.from(registeredClient).clientSettings(clientSettingsBuilder.build()).build();
+		}
+
+	}
+
+	private static final class CustomClientRegistrationConverter
+			implements Converter<RegisteredClient, OidcClientRegistration> {
+
+		private final RegisteredClientOidcClientRegistrationConverter delegate = new RegisteredClientOidcClientRegistrationConverter();
+
+		private final List<String> supportedCustomClientMetadata;
+
+		private CustomClientRegistrationConverter(List<String> supportedCustomClientMetadata) {
+			this.supportedCustomClientMetadata = supportedCustomClientMetadata;
+		}
+
+		@Override
+		public OidcClientRegistration convert(RegisteredClient registeredClient) {
+			OidcClientRegistration clientRegistration = this.delegate.convert(registeredClient);
+
+			Map<String, Object> clientMetadata = new HashMap<>(clientRegistration.getClaims());
+			if (!CollectionUtils.isEmpty(this.supportedCustomClientMetadata)) {
+				Map<String, Object> clientSettings = registeredClient.getClientSettings().getSettings();
+				this.supportedCustomClientMetadata.forEach((customClaim) -> {
+					if (clientSettings.containsKey(customClaim)) {
+						clientMetadata.put(customClaim, clientSettings.get(customClaim));
+					}
+				});
+			}
+
+			return OidcClientRegistration.withClaims(clientMetadata).build();
+		}
+
+	}
+
+	/**
+	 * This customization adds client secret expiration time by setting
+	 * {@code RegisteredClient.clientSecretExpiresAt} during
+	 * {@code OidcClientRegistration} -> {@code RegisteredClient} conversion
+	 */
+	private static final class ClientSecretExpirationRegisteredClientConverter
+			implements Converter<OidcClientRegistration, RegisteredClient> {
+
+		private static final OidcClientRegistrationRegisteredClientConverter delegate = new OidcClientRegistrationRegisteredClientConverter();
+
+		@Override
+		public RegisteredClient convert(OidcClientRegistration clientRegistration) {
+			RegisteredClient registeredClient = delegate.convert(clientRegistration);
+			RegisteredClient.Builder registeredClientBuilder = RegisteredClient.from(registeredClient);
+
+			Instant clientSecretExpiresAt = Instant.now().plus(Duration.ofHours(24));
+			registeredClientBuilder.clientSecretExpiresAt(clientSecretExpiresAt);
+
+			return registeredClientBuilder.build();
+		}
+
+	}
+
+}

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

@@ -0,0 +1,410 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.util.function.Consumer;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+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.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadataClaimNames;
+import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcProviderConfiguration;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultMatcher;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.hamcrest.CoreMatchers.hasItems;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OpenID Connect 1.0 Provider Configuration endpoint.
+ *
+ * @author Sahariar Alam Khandoker
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OidcProviderConfigurationTests {
+
+	private static final String DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI = "/.well-known/openid-configuration";
+
+	private static final String ISSUER = "https://example.com";
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Test
+	public void requestWhenConfigurationRequestAndIssuerSetThenReturnDefaultConfigurationResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpectAll(defaultConfigurationMatchers(ISSUER));
+	}
+
+	@Test
+	public void requestWhenConfigurationRequestIncludesIssuerPathThenConfigurationResponseHasIssuerPath()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithMultipleIssuersAllowed.class).autowire();
+
+		String issuer = "https://example.com:8443/issuer1";
+		this.mvc.perform(get(issuer.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpectAll(defaultConfigurationMatchers(issuer));
+
+		issuer = "https://example.com:8443/path1/issuer2";
+		this.mvc.perform(get(issuer.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpectAll(defaultConfigurationMatchers(issuer));
+
+		issuer = "https://example.com:8443/path1/path2/issuer3";
+		this.mvc.perform(get(issuer.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpectAll(defaultConfigurationMatchers(issuer));
+	}
+
+	// gh-632
+	@Test
+	public void requestWhenConfigurationRequestAndUserAuthenticatedThenReturnConfigurationResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)).with(user("user")))
+			.andExpect(status().is2xxSuccessful())
+			.andExpectAll(defaultConfigurationMatchers(ISSUER));
+	}
+
+	// gh-616
+	@Test
+	public void requestWhenConfigurationRequestAndConfigurationCustomizerSetThenReturnCustomConfigurationResponse()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithProviderConfigurationCustomizer.class).autowire();
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpect(jsonPath(OAuth2AuthorizationServerMetadataClaimNames.SCOPES_SUPPORTED,
+					hasItems(OidcScopes.OPENID, OidcScopes.PROFILE, OidcScopes.EMAIL)));
+	}
+
+	@Test
+	public void requestWhenConfigurationRequestAndClientRegistrationEnabledThenConfigurationResponseIncludesRegistrationEndpoint()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithClientRegistrationEnabled.class).autowire();
+
+		this.mvc.perform(get(ISSUER.concat(DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI)))
+			.andExpect(status().is2xxSuccessful())
+			.andExpectAll(defaultConfigurationMatchers(ISSUER))
+			.andExpect(jsonPath("$.registration_endpoint")
+				.value(ISSUER.concat(this.authorizationServerSettings.getOidcClientRegistrationEndpoint())));
+	}
+
+	private ResultMatcher[] defaultConfigurationMatchers(String issuer) {
+		// @formatter:off
+		return new ResultMatcher[] {
+				jsonPath("issuer").value(issuer),
+				jsonPath("authorization_endpoint").value(issuer.concat(this.authorizationServerSettings.getAuthorizationEndpoint())),
+				jsonPath("token_endpoint").value(issuer.concat(this.authorizationServerSettings.getTokenEndpoint())),
+				jsonPath("$.token_endpoint_auth_methods_supported[0]").value(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()),
+				jsonPath("$.token_endpoint_auth_methods_supported[1]").value(ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()),
+				jsonPath("$.token_endpoint_auth_methods_supported[2]").value(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()),
+				jsonPath("$.token_endpoint_auth_methods_supported[3]").value(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()),
+				jsonPath("jwks_uri").value(issuer.concat(this.authorizationServerSettings.getJwkSetEndpoint())),
+				jsonPath("userinfo_endpoint").value(issuer.concat(this.authorizationServerSettings.getOidcUserInfoEndpoint())),
+				jsonPath("end_session_endpoint").value(issuer.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()),
+				jsonPath("$.grant_types_supported[2]").value(AuthorizationGrantType.REFRESH_TOKEN.getValue()),
+				jsonPath("revocation_endpoint").value(issuer.concat(this.authorizationServerSettings.getTokenRevocationEndpoint())),
+				jsonPath("$.revocation_endpoint_auth_methods_supported[0]").value(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()),
+				jsonPath("$.revocation_endpoint_auth_methods_supported[1]").value(ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()),
+				jsonPath("$.revocation_endpoint_auth_methods_supported[2]").value(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()),
+				jsonPath("$.revocation_endpoint_auth_methods_supported[3]").value(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()),
+				jsonPath("introspection_endpoint").value(issuer.concat(this.authorizationServerSettings.getTokenIntrospectionEndpoint())),
+				jsonPath("$.introspection_endpoint_auth_methods_supported[0]").value(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue()),
+				jsonPath("$.introspection_endpoint_auth_methods_supported[1]").value(ClientAuthenticationMethod.CLIENT_SECRET_POST.getValue()),
+				jsonPath("$.introspection_endpoint_auth_methods_supported[2]").value(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue()),
+				jsonPath("$.introspection_endpoint_auth_methods_supported[3]").value(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue()),
+				jsonPath("$.code_challenge_methods_supported[0]").value("S256"),
+				jsonPath("subject_types_supported").value("public"),
+				jsonPath("id_token_signing_alg_values_supported").value(SignatureAlgorithm.RS256.getName()),
+				jsonPath("scopes_supported").value(OidcScopes.OPENID)
+		};
+		// @formatter:on
+	}
+
+	@Test
+	public void loadContextWhenIssuerNotValidUrlThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithInvalidIssuerUrl.class).autowire());
+	}
+
+	@Test
+	public void loadContextWhenIssuerNotValidUriThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithInvalidIssuerUri.class).autowire());
+	}
+
+	@Test
+	public void loadContextWhenIssuerWithQueryThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithIssuerQuery.class).autowire());
+	}
+
+	@Test
+	public void loadContextWhenIssuerWithFragmentThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithIssuerFragment.class).autowire());
+	}
+
+	@Test
+	public void loadContextWhenIssuerWithQueryAndFragmentThenThrowException() {
+		assertThatThrownBy(() -> this.spring.register(AuthorizationServerConfigurationWithIssuerQueryAndFragment.class)
+			.autowire());
+	}
+
+	@Test
+	public void loadContextWhenIssuerWithEmptyQueryThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithIssuerEmptyQuery.class).autowire());
+	}
+
+	@Test
+	public void loadContextWhenIssuerWithEmptyFragmentThenThrowException() {
+		assertThatThrownBy(
+				() -> this.spring.register(AuthorizationServerConfigurationWithIssuerEmptyFragment.class).autowire());
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = OAuth2AuthorizationServerConfigurer
+				.authorizationServer();
+			// @formatter:off
+			http
+				.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+				.with(authorizationServerConfigurer, (authorizationServer) ->
+					authorizationServer
+						.oidc(Customizer.withDefaults())	// Enable OpenID Connect 1.0
+				);
+			// @formatter:on
+			return http.build();
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository() {
+			RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+			return new InMemoryRegisteredClientRepository(registeredClient);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return new ImmutableJWKSet<>(new JWKSet(TestJwks.DEFAULT_RSA_JWK));
+		}
+
+		@Bean
+		JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
+			return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+		}
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER).build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithMultipleIssuersAllowed extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithProviderConfigurationCustomizer
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc.providerConfigurationEndpoint((providerConfigurationEndpoint) ->
+													providerConfigurationEndpoint
+															.providerConfigurationCustomizer(providerConfigurationCustomizer())))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		private Consumer<OidcProviderConfiguration.Builder> providerConfigurationCustomizer() {
+			return (providerConfiguration) -> providerConfiguration.scope(OidcScopes.PROFILE).scope(OidcScopes.EMAIL);
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithClientRegistrationEnabled
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc.clientRegistrationEndpoint(Customizer.withDefaults())
+									)
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithInvalidIssuerUrl extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer("urn:example").build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithInvalidIssuerUri extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer("https://not a valid uri").build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithIssuerQuery extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER + "?param=value").build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithIssuerFragment extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER + "#fragment").build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithIssuerQueryAndFragment extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER + "?param=value#fragment").build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithIssuerEmptyQuery extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER + "?").build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithIssuerEmptyFragment extends AuthorizationServerConfiguration {
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().issuer(ISSUER + "#").build();
+		}
+
+	}
+
+}

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

@@ -0,0 +1,787 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.io.UnsupportedEncodingException;
+import java.net.URLDecoder;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.Base64;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.jdbc.core.JdbcOperations;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabase;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseBuilder;
+import org.springframework.jdbc.datasource.embedded.EmbeddedDatabaseType;
+import org.springframework.lang.Nullable;
+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;
+import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.session.SessionRegistry;
+import org.springframework.security.core.session.SessionRegistryImpl;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.OAuth2Token;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AccessTokenResponse;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2AccessTokenResponseHttpMessageConverter;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationCode;
+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.JdbcRegisteredClientRepository;
+import org.springframework.security.oauth2.server.authorization.client.JdbcRegisteredClientRepository.RegisteredClientParametersMapper;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.jackson2.TestingAuthenticationTokenMixin;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.authorization.token.DelegatingOAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2RefreshTokenGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.util.StringUtils;
+import org.springframework.web.util.UriComponents;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for OpenID Connect 1.0.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
+ */
+@ExtendWith(SpringTestContextExtension.class)
+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;
+
+	private static JWKSource<SecurityContext> jwkSource;
+
+	private static HttpMessageConverter<OAuth2AccessTokenResponse> accessTokenHttpResponseConverter = new OAuth2AccessTokenResponseHttpMessageConverter();
+
+	private static SessionRegistry sessionRegistry;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JdbcOperations jdbcOperations;
+
+	@Autowired
+	private RegisteredClientRepository registeredClientRepository;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	@Autowired
+	private JwtDecoder jwtDecoder;
+
+	@Autowired(required = false)
+	private OAuth2TokenGenerator<?> tokenGenerator;
+
+	@BeforeAll
+	public static void init() {
+		JWKSet jwkSet = new JWKSet(TestJwks.DEFAULT_RSA_JWK);
+		jwkSource = (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
+		db = new EmbeddedDatabaseBuilder().generateUniqueName(true)
+			.setType(EmbeddedDatabaseType.HSQL)
+			.setScriptEncoding("UTF-8")
+			.addScript("org/springframework/security/oauth2/server/authorization/oauth2-authorization-schema.sql")
+			.addScript(
+					"org/springframework/security/oauth2/server/authorization/client/oauth2-registered-client-schema.sql")
+			.build();
+		sessionRegistry = spy(new SessionRegistryImpl());
+	}
+
+	@AfterEach
+	public void tearDown() {
+		if (this.jdbcOperations != null) {
+			this.jdbcOperations.update("truncate table oauth2_authorization");
+			this.jdbcOperations.update("truncate table oauth2_registered_client");
+		}
+	}
+
+	@AfterAll
+	public static void destroy() {
+		db.shutdown();
+	}
+
+	@Test
+	public void requestWhenAuthenticationRequestThenTokenResponseIncludesIdToken() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user").roles("A", "B")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=state");
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andExpect(jsonPath("$.id_token").isNotEmpty())
+			.andReturn();
+
+		MockHttpServletResponse servletResponse = mvcResult.getResponse();
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		OAuth2AccessTokenResponse accessTokenResponse = accessTokenHttpResponseConverter
+			.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		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<>();
+		for (GrantedAuthority authority : principal.getAuthorities()) {
+			userAuthorities.add(authority.getAuthority());
+		}
+		assertThat(authoritiesClaim).containsExactlyInAnyOrderElementsOf(userAuthorities);
+
+		// Assert sid claim was added in ID Token
+		assertThat(idToken.<String>getClaim("sid")).isNotNull();
+	}
+
+	// gh-1224
+	@Test
+	public void requestWhenRefreshTokenRequestThenIdTokenContainsSidClaim() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user").roles("A", "B")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=state");
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.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);
+
+		Jwt idToken = this.jwtDecoder
+			.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN));
+
+		String sidClaim = idToken.getClaim("sid");
+		assertThat(sidClaim).isNotNull();
+
+		// Refresh access token
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.param(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.REFRESH_TOKEN.getValue())
+				.param(OAuth2ParameterNames.REFRESH_TOKEN, accessTokenResponse.getRefreshToken().getTokenValue())
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andReturn();
+
+		servletResponse = mvcResult.getResponse();
+		httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		idToken = this.jwtDecoder
+			.decode((String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN));
+
+		assertThat(idToken.<String>getClaim("sid")).isEqualTo(sidClaim);
+	}
+
+	@Test
+	public void requestWhenLogoutRequestThenLogout() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		// Login
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(issuer.concat(DEFAULT_AUTHORIZATION_ENDPOINT_URI)).queryParams(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(issuer.concat(DEFAULT_TOKEN_ENDPOINT_URI))
+				.params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.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(issuer.concat(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
+	public void requestWhenLogoutRequestWithOtherUsersIdTokenThenNotLogout() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		// Login user1
+		RegisteredClient registeredClient1 = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient1);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient1);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user1")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		MockHttpSession user1Session = (MockHttpSession) mvcResult.getRequest().getSession();
+		assertThat(user1Session.isNew()).isTrue();
+
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization user1Authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient1, user1Authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient1.getClientId(),
+								registeredClient1.getClientSecret())))
+			.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 user1IdToken = (String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN);
+
+		// Login user2
+		RegisteredClient registeredClient2 = TestRegisteredClients.registeredClient2().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient2);
+
+		authorizationRequestParameters = getAuthorizationRequestParameters(registeredClient2);
+		mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user2")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+
+		MockHttpSession user2Session = (MockHttpSession) mvcResult.getRequest().getSession();
+		assertThat(user2Session.isNew()).isTrue();
+
+		redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization user2Authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI)
+				.params(getTokenRequestParameters(registeredClient2, user2Authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient2.getClientId(),
+								registeredClient2.getClientSecret())))
+			.andExpect(status().isOk())
+			.andReturn();
+
+		servletResponse = mvcResult.getResponse();
+		httpResponse = new MockClientHttpResponse(servletResponse.getContentAsByteArray(),
+				HttpStatus.valueOf(servletResponse.getStatus()));
+		accessTokenResponse = accessTokenHttpResponseConverter.read(OAuth2AccessTokenResponse.class, httpResponse);
+
+		String user2IdToken = (String) accessTokenResponse.getAdditionalParameters().get(OidcParameterNames.ID_TOKEN);
+
+		// Attempt to log out user1 using user2's ID Token
+		mvcResult = this.mvc
+			.perform(post(DEFAULT_OIDC_LOGOUT_ENDPOINT_URI).param("id_token_hint", user2IdToken).session(user1Session))
+			.andExpect(status().isBadRequest())
+			.andExpect(status().reason("[invalid_token] OpenID Connect 1.0 Logout Request Parameter: sub"))
+			.andReturn();
+
+		assertThat(user1Session.isInvalid()).isFalse();
+	}
+
+	@Test
+	public void requestWhenCustomTokenGeneratorThenUsed() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithTokenGenerator.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		this.authorizationService.save(authorization);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk());
+
+		verify(this.tokenGenerator, times(3)).generate(any());
+	}
+
+	// gh-1422
+	@Test
+	public void requestWhenAuthenticationRequestWithOfflineAccessScopeThenTokenResponseIncludesRefreshToken()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithCustomRefreshTokenGenerator.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.scope(OidcScopes.OPENID)
+			.scope("offline_access")
+			.build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=state");
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").isNotEmpty())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andExpect(jsonPath("$.id_token").isNotEmpty())
+			.andReturn();
+	}
+
+	// gh-1422
+	@Test
+	public void requestWhenAuthenticationRequestWithoutOfflineAccessScopeThenTokenResponseDoesNotIncludeRefreshToken()
+			throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithCustomRefreshTokenGenerator.class).autowire();
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		this.registeredClientRepository.save(registeredClient);
+
+		MultiValueMap<String, String> authorizationRequestParameters = getAuthorizationRequestParameters(
+				registeredClient);
+		MvcResult mvcResult = this.mvc
+			.perform(get(DEFAULT_AUTHORIZATION_ENDPOINT_URI).queryParams(authorizationRequestParameters)
+				.with(user("user")))
+			.andExpect(status().is3xxRedirection())
+			.andReturn();
+		String redirectedUrl = mvcResult.getResponse().getRedirectedUrl();
+		String expectedRedirectUri = authorizationRequestParameters.getFirst(OAuth2ParameterNames.REDIRECT_URI);
+		assertThat(redirectedUrl).matches(expectedRedirectUri + "\\?code=.{15,}&state=state");
+
+		String authorizationCode = extractParameterFromRedirectUri(redirectedUrl, "code");
+		OAuth2Authorization authorization = this.authorizationService.findByToken(authorizationCode,
+				AUTHORIZATION_CODE_TOKEN_TYPE);
+
+		this.mvc
+			.perform(post(DEFAULT_TOKEN_ENDPOINT_URI).params(getTokenRequestParameters(registeredClient, authorization))
+				.header(HttpHeaders.AUTHORIZATION,
+						"Basic " + encodeBasicAuth(registeredClient.getClientId(), registeredClient.getClientSecret())))
+			.andExpect(status().isOk())
+			.andExpect(header().string(HttpHeaders.CACHE_CONTROL, containsString("no-store")))
+			.andExpect(header().string(HttpHeaders.PRAGMA, containsString("no-cache")))
+			.andExpect(jsonPath("$.access_token").isNotEmpty())
+			.andExpect(jsonPath("$.token_type").isNotEmpty())
+			.andExpect(jsonPath("$.expires_in").isNotEmpty())
+			.andExpect(jsonPath("$.refresh_token").doesNotExist())
+			.andExpect(jsonPath("$.scope").isNotEmpty())
+			.andExpect(jsonPath("$.id_token").isNotEmpty())
+			.andReturn();
+	}
+
+	private static MultiValueMap<String, String> getAuthorizationRequestParameters(RegisteredClient registeredClient) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.RESPONSE_TYPE, OAuth2AuthorizationResponseType.CODE.getValue());
+		parameters.set(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId());
+		parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
+		parameters.set(OAuth2ParameterNames.SCOPE,
+				StringUtils.collectionToDelimitedString(registeredClient.getScopes(), " "));
+		parameters.set(OAuth2ParameterNames.STATE, "state");
+		return parameters;
+	}
+
+	private static MultiValueMap<String, String> getTokenRequestParameters(RegisteredClient registeredClient,
+			OAuth2Authorization authorization) {
+		MultiValueMap<String, String> parameters = new LinkedMultiValueMap<>();
+		parameters.set(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
+		parameters.set(OAuth2ParameterNames.CODE,
+				authorization.getToken(OAuth2AuthorizationCode.class).getToken().getTokenValue());
+		parameters.set(OAuth2ParameterNames.REDIRECT_URI, registeredClient.getRedirectUris().iterator().next());
+		return parameters;
+	}
+
+	private static String encodeBasicAuth(String clientId, String secret) throws Exception {
+		clientId = URLEncoder.encode(clientId, StandardCharsets.UTF_8.name());
+		secret = URLEncoder.encode(secret, StandardCharsets.UTF_8.name());
+		String credentialsString = clientId + ":" + secret;
+		byte[] encodedBytes = Base64.getEncoder().encode(credentialsString.getBytes(StandardCharsets.UTF_8));
+		return new String(encodedBytes, StandardCharsets.UTF_8);
+	}
+
+	private String extractParameterFromRedirectUri(String redirectUri, String param)
+			throws UnsupportedEncodingException {
+		String locationHeader = URLDecoder.decode(redirectUri, StandardCharsets.UTF_8.name());
+		UriComponents uriComponents = UriComponentsBuilder.fromUriString(locationHeader).build();
+		return uriComponents.getQueryParams().getFirst(param);
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			// @formatter:off
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+				.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+				.with(authorizationServerConfigurer, (authorizationServer) ->
+					authorizationServer
+						.oidc(Customizer.withDefaults())	// Enable OpenID Connect 1.0
+				);
+			// @formatter:on
+			return http.build();
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
+				RegisteredClientRepository registeredClientRepository) {
+			JdbcOAuth2AuthorizationService authorizationService = new JdbcOAuth2AuthorizationService(jdbcOperations,
+					registeredClientRepository);
+			authorizationService.setAuthorizationRowMapper(new RowMapper(registeredClientRepository));
+			authorizationService.setAuthorizationParametersMapper(new ParametersMapper());
+			return authorizationService;
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
+			JdbcRegisteredClientRepository jdbcRegisteredClientRepository = new JdbcRegisteredClientRepository(
+					jdbcOperations);
+			RegisteredClientParametersMapper registeredClientParametersMapper = new RegisteredClientParametersMapper();
+			jdbcRegisteredClientRepository.setRegisteredClientParametersMapper(registeredClientParametersMapper);
+			return jdbcRegisteredClientRepository;
+		}
+
+		@Bean
+		JdbcOperations jdbcOperations() {
+			return new JdbcTemplate(db);
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return jwkSource;
+		}
+
+		@Bean
+		JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
+			return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+		}
+
+		@Bean
+		OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer() {
+			return (context) -> {
+				if (context.getTokenType().getValue().equals(OidcParameterNames.ID_TOKEN)) {
+					Authentication principal = context.getPrincipal();
+					Set<String> authorities = new HashSet<>();
+					for (GrantedAuthority authority : principal.getAuthorities()) {
+						authorities.add(authority.getAuthority());
+					}
+					context.getClaims().claim(AUTHORITIES_CLAIM, authorities);
+				}
+			};
+		}
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+		@Bean
+		PasswordEncoder passwordEncoder() {
+			return NoOpPasswordEncoder.getInstance();
+		}
+
+		@Bean
+		SessionRegistry sessionRegistry() {
+			return sessionRegistry;
+		}
+
+		static class RowMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationRowMapper {
+
+			RowMapper(RegisteredClientRepository registeredClientRepository) {
+				super(registeredClientRepository);
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+		static class ParametersMapper extends JdbcOAuth2AuthorizationService.OAuth2AuthorizationParametersMapper {
+
+			ParametersMapper() {
+				super();
+				getObjectMapper().addMixIn(TestingAuthenticationToken.class, TestingAuthenticationTokenMixin.class);
+			}
+
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration
+	static class AuthorizationServerConfigurationWithTokenGenerator extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.tokenGenerator(tokenGenerator())
+									.oidc(Customizer.withDefaults())
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		@Bean
+		OAuth2TokenGenerator<?> tokenGenerator() {
+			JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource()));
+			jwtGenerator.setJwtCustomizer(jwtCustomizer());
+			OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator();
+			OAuth2TokenGenerator<OAuth2Token> delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(
+					jwtGenerator, refreshTokenGenerator);
+			return spy(new OAuth2TokenGenerator<OAuth2Token>() {
+				@Override
+				public OAuth2Token generate(OAuth2TokenContext context) {
+					return delegatingTokenGenerator.generate(context);
+				}
+			});
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration
+	static class AuthorizationServerConfigurationWithCustomRefreshTokenGenerator
+			extends AuthorizationServerConfiguration {
+
+		// @formatter:off
+		@Bean
+		SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.tokenGenerator(tokenGenerator())
+									.oidc(Customizer.withDefaults())
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			return http.build();
+		}
+		// @formatter:on
+
+		@Bean
+		OAuth2TokenGenerator<?> tokenGenerator() {
+			JwtGenerator jwtGenerator = new JwtGenerator(new NimbusJwtEncoder(jwkSource()));
+			jwtGenerator.setJwtCustomizer(jwtCustomizer());
+			OAuth2TokenGenerator<OAuth2RefreshToken> refreshTokenGenerator = new CustomRefreshTokenGenerator();
+			return new DelegatingOAuth2TokenGenerator(jwtGenerator, refreshTokenGenerator);
+		}
+
+		private static final class CustomRefreshTokenGenerator implements OAuth2TokenGenerator<OAuth2RefreshToken> {
+
+			private final OAuth2RefreshTokenGenerator delegate = new OAuth2RefreshTokenGenerator();
+
+			@Nullable
+			@Override
+			public OAuth2RefreshToken generate(OAuth2TokenContext context) {
+				if (context.getAuthorizedScopes().contains(OidcScopes.OPENID)
+						&& !context.getAuthorizedScopes().contains("offline_access")) {
+					return null;
+				}
+				return this.delegate.generate(context);
+			}
+
+		}
+
+	}
+
+}

+ 520 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/config/annotation/web/configurers/OidcUserInfoTests.java

@@ -0,0 +1,520 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.config.annotation.web.configurers;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Function;
+
+import com.nimbusds.jose.jwk.JWKSet;
+import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
+import com.nimbusds.jose.jwk.source.JWKSource;
+import com.nimbusds.jose.proc.SecurityContext;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.security.authentication.AuthenticationProvider;
+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.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.jose.TestJwks;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
+import org.springframework.security.oauth2.server.authorization.InMemoryOAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository;
+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.config.annotation.web.configuration.OAuth2AuthorizationServerConfiguration;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationContext;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationProvider;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContext;
+import org.springframework.security.oauth2.server.authorization.test.SpringTestContextExtension;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.security.web.SecurityFilterChain;
+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.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.context.SecurityContextRepository;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.ResultMatcher;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.BDDMockito.willAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+/**
+ * Integration tests for the OpenID Connect 1.0 UserInfo endpoint.
+ *
+ * @author Steve Riesenberg
+ */
+@ExtendWith(SpringTestContextExtension.class)
+public class OidcUserInfoTests {
+
+	private static final String DEFAULT_OIDC_USER_INFO_ENDPOINT_URI = "/userinfo";
+
+	private static SecurityContextRepository securityContextRepository;
+
+	public final SpringTestContext spring = new SpringTestContext();
+
+	@Autowired
+	private MockMvc mvc;
+
+	@Autowired
+	private JwtEncoder jwtEncoder;
+
+	@Autowired
+	private JwtDecoder jwtDecoder;
+
+	@Autowired
+	private OAuth2AuthorizationService authorizationService;
+
+	private static AuthenticationConverter authenticationConverter;
+
+	private static Consumer<List<AuthenticationConverter>> authenticationConvertersConsumer;
+
+	private static AuthenticationProvider authenticationProvider;
+
+	private static Consumer<List<AuthenticationProvider>> authenticationProvidersConsumer;
+
+	private static AuthenticationSuccessHandler authenticationSuccessHandler;
+
+	private static AuthenticationFailureHandler authenticationFailureHandler;
+
+	private static Function<OidcUserInfoAuthenticationContext, OidcUserInfo> userInfoMapper;
+
+	@BeforeAll
+	public static void init() {
+		securityContextRepository = spy(new HttpSessionSecurityContextRepository());
+		authenticationConverter = mock(AuthenticationConverter.class);
+		authenticationConvertersConsumer = mock(Consumer.class);
+		authenticationProvider = mock(AuthenticationProvider.class);
+		authenticationProvidersConsumer = mock(Consumer.class);
+		authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		userInfoMapper = mock(Function.class);
+	}
+
+	@BeforeEach
+	public void setup() {
+		reset(securityContextRepository);
+		reset(authenticationConverter);
+		reset(authenticationConvertersConsumer);
+		reset(authenticationProvider);
+		reset(authenticationProvidersConsumer);
+		reset(authenticationSuccessHandler);
+		reset(authenticationFailureHandler);
+		reset(userInfoMapper);
+	}
+
+	@Test
+	public void requestWhenUserInfoRequestGetThenUserInfoResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		OAuth2Authorization authorization = createAuthorization();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		// @formatter:off
+		this.mvc.perform(get(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI)
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is2xxSuccessful())
+				.andExpectAll(userInfoResponse());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenUserInfoRequestPostThenUserInfoResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		OAuth2Authorization authorization = createAuthorization();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		// @formatter:off
+		this.mvc.perform(post(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI)
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is2xxSuccessful())
+				.andExpectAll(userInfoResponse());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenUserInfoRequestIncludesIssuerPathThenUserInfoResponse() throws Exception {
+		this.spring.register(AuthorizationServerConfiguration.class).autowire();
+
+		OAuth2Authorization authorization = createAuthorization();
+		this.authorizationService.save(authorization);
+
+		String issuer = "https://example.com:8443/issuer1";
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		// @formatter:off
+		this.mvc.perform(get(issuer.concat(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI))
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is2xxSuccessful())
+				.andExpectAll(userInfoResponse());
+		// @formatter:on
+	}
+
+	@Test
+	public void requestWhenUserInfoEndpointCustomizedThenUsed() throws Exception {
+		this.spring.register(CustomUserInfoConfiguration.class).autowire();
+
+		OAuth2Authorization authorization = createAuthorization();
+		this.authorizationService.save(authorization);
+
+		given(userInfoMapper.apply(any())).willReturn(createUserInfo());
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		// @formatter:off
+		this.mvc.perform(get(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI)
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is2xxSuccessful());
+		// @formatter:on
+
+		verify(userInfoMapper).apply(any());
+		verify(authenticationConverter).convert(any());
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), any());
+		verifyNoInteractions(authenticationFailureHandler);
+
+		ArgumentCaptor<List<AuthenticationProvider>> authenticationProvidersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationProvidersConsumer).accept(authenticationProvidersCaptor.capture());
+		List<AuthenticationProvider> authenticationProviders = authenticationProvidersCaptor.getValue();
+		assertThat(authenticationProviders).hasSize(2)
+			.allMatch((provider) -> provider == authenticationProvider
+					|| provider instanceof OidcUserInfoAuthenticationProvider);
+
+		ArgumentCaptor<List<AuthenticationConverter>> authenticationConvertersCaptor = ArgumentCaptor
+			.forClass(List.class);
+		verify(authenticationConvertersConsumer).accept(authenticationConvertersCaptor.capture());
+		List<AuthenticationConverter> authenticationConverters = authenticationConvertersCaptor.getValue();
+		assertThat(authenticationConverters).hasSize(2).allMatch(AuthenticationConverter.class::isInstance);
+	}
+
+	@Test
+	public void requestWhenUserInfoEndpointCustomizedWithAuthenticationProviderThenUsed() throws Exception {
+		this.spring.register(CustomUserInfoConfiguration.class).autowire();
+
+		OAuth2Authorization authorization = createAuthorization();
+		this.authorizationService.save(authorization);
+
+		given(authenticationProvider.supports(eq(OidcUserInfoAuthenticationToken.class))).willReturn(true);
+		String tokenValue = authorization.getAccessToken().getToken().getTokenValue();
+		Jwt jwt = this.jwtDecoder.decode(tokenValue);
+		OidcUserInfoAuthenticationToken oidcUserInfoAuthentication = new OidcUserInfoAuthenticationToken(
+				new JwtAuthenticationToken(jwt), createUserInfo());
+		given(authenticationProvider.authenticate(any())).willReturn(oidcUserInfoAuthentication);
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		// @formatter:off
+		this.mvc.perform(get(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI)
+						.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is2xxSuccessful());
+		// @formatter:on
+
+		verify(authenticationSuccessHandler).onAuthenticationSuccess(any(), any(), any());
+		verify(authenticationProvider).authenticate(any());
+		verifyNoInteractions(authenticationFailureHandler);
+		verifyNoInteractions(userInfoMapper);
+	}
+
+	@Test
+	public void requestWhenUserInfoEndpointCustomizedWithAuthenticationFailureHandlerThenUsed() throws Exception {
+		this.spring.register(CustomUserInfoConfiguration.class).autowire();
+
+		given(userInfoMapper.apply(any())).willReturn(createUserInfo());
+		willAnswer((invocation) -> {
+			HttpServletResponse response = invocation.getArgument(1);
+			response.setStatus(HttpStatus.UNAUTHORIZED.value());
+			response.getWriter().write("unauthorized");
+			return null;
+		}).given(authenticationFailureHandler).onAuthenticationFailure(any(), any(), any());
+
+		OAuth2AccessToken accessToken = createAuthorization().getAccessToken().getToken();
+		// @formatter:off
+		this.mvc.perform(get(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI)
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is4xxClientError());
+		// @formatter:on
+
+		verify(authenticationFailureHandler).onAuthenticationFailure(any(), any(), any());
+		verifyNoInteractions(authenticationSuccessHandler);
+		verifyNoInteractions(userInfoMapper);
+	}
+
+	// gh-482
+	@Test
+	public void requestWhenUserInfoRequestThenBearerTokenAuthenticationNotPersisted() throws Exception {
+		this.spring.register(AuthorizationServerConfigurationWithSecurityContextRepository.class).autowire();
+
+		OAuth2Authorization authorization = createAuthorization();
+		this.authorizationService.save(authorization);
+
+		OAuth2AccessToken accessToken = authorization.getAccessToken().getToken();
+		// @formatter:off
+		MvcResult mvcResult = this.mvc.perform(get(DEFAULT_OIDC_USER_INFO_ENDPOINT_URI)
+				.header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken.getTokenValue()))
+				.andExpect(status().is2xxSuccessful())
+				.andExpectAll(userInfoResponse())
+				.andReturn();
+		// @formatter:on
+
+		org.springframework.security.core.context.SecurityContext securityContext = securityContextRepository
+			.loadDeferredContext(mvcResult.getRequest())
+			.get();
+		assertThat(securityContext.getAuthentication()).isNull();
+	}
+
+	private static ResultMatcher[] userInfoResponse() {
+		// @formatter:off
+		return new ResultMatcher[] {
+				jsonPath("sub").value("user1"),
+				jsonPath("name").value("First Last"),
+				jsonPath("given_name").value("First"),
+				jsonPath("family_name").value("Last"),
+				jsonPath("middle_name").value("Middle"),
+				jsonPath("nickname").value("User"),
+				jsonPath("preferred_username").value("user"),
+				jsonPath("profile").value("https://example.com/user1"),
+				jsonPath("picture").value("https://example.com/user1.jpg"),
+				jsonPath("website").value("https://example.com"),
+				jsonPath("email").value("user1@example.com"),
+				jsonPath("email_verified").value("true"),
+				jsonPath("gender").value("female"),
+				jsonPath("birthdate").value("1970-01-01"),
+				jsonPath("zoneinfo").value("Europe/Paris"),
+				jsonPath("locale").value("en-US"),
+				jsonPath("phone_number").value("+1 (604) 555-1234;ext=5678"),
+				jsonPath("phone_number_verified").value("false"),
+				jsonPath("address.formatted").value("Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"),
+				jsonPath("updated_at").value("1970-01-01T00:00:00Z")
+		};
+		// @formatter:on
+	}
+
+	private OAuth2Authorization createAuthorization() {
+		JwsHeader headers = JwsHeader.with(SignatureAlgorithm.RS256).build();
+		// @formatter:off
+		JwtClaimsSet claimSet = JwtClaimsSet.builder()
+				.claims((claims) -> claims.putAll(createUserInfo().getClaims()))
+				.build();
+		// @formatter:on
+		Jwt jwt = this.jwtEncoder.encode(JwtEncoderParameters.from(headers, claimSet));
+
+		Instant now = Instant.now();
+		Set<String> scopes = new HashSet<>(Arrays.asList(OidcScopes.OPENID, OidcScopes.ADDRESS, OidcScopes.EMAIL,
+				OidcScopes.PHONE, OidcScopes.PROFILE));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, jwt.getTokenValue(),
+				now, now.plusSeconds(300), scopes);
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.claims((claims) -> claims.putAll(createUserInfo().getClaims()))
+			.build();
+
+		return TestOAuth2Authorizations.authorization().accessToken(accessToken).token(idToken).build();
+	}
+
+	private static OidcUserInfo createUserInfo() {
+		// @formatter:off
+		return OidcUserInfo.builder()
+				.subject("user1")
+				.name("First Last")
+				.givenName("First")
+				.familyName("Last")
+				.middleName("Middle")
+				.nickname("User")
+				.preferredUsername("user")
+				.profile("https://example.com/user1")
+				.picture("https://example.com/user1.jpg")
+				.website("https://example.com")
+				.email("user1@example.com")
+				.emailVerified(true)
+				.gender("female")
+				.birthdate("1970-01-01")
+				.zoneinfo("Europe/Paris")
+				.locale("en-US")
+				.phoneNumber("+1 (604) 555-1234;ext=5678")
+				.phoneNumberVerified(false)
+				.claim("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"))
+				.updatedAt("1970-01-01T00:00:00Z")
+				.build();
+		// @formatter:on
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class CustomUserInfoConfiguration extends AuthorizationServerConfiguration {
+
+		@Bean
+		@Override
+		SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+			// @formatter:off
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc((oidc) ->
+											oidc
+													.userInfoEndpoint((userInfo) ->
+															userInfo
+																	.userInfoRequestConverter(authenticationConverter)
+																	.userInfoRequestConverters(authenticationConvertersConsumer)
+																	.authenticationProvider(authenticationProvider)
+																	.authenticationProviders(authenticationProvidersConsumer)
+																	.userInfoResponseHandler(authenticationSuccessHandler)
+																	.errorResponseHandler(authenticationFailureHandler)
+																	.userInfoMapper(userInfoMapper)))
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			// @formatter:on
+			return http.build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfigurationWithSecurityContextRepository
+			extends AuthorizationServerConfiguration {
+
+		@Bean
+		@Override
+		SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+			// @formatter:off
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc(Customizer.withDefaults())
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					)
+					.securityContext((securityContext) ->
+							securityContext.securityContextRepository(securityContextRepository));
+			// @formatter:on
+
+			return http.build();
+		}
+
+	}
+
+	@EnableWebSecurity
+	@Configuration(proxyBeanMethods = false)
+	static class AuthorizationServerConfiguration {
+
+		@Bean
+		SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
+			// @formatter:off
+			OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
+					OAuth2AuthorizationServerConfigurer.authorizationServer();
+			http
+					.securityMatcher(authorizationServerConfigurer.getEndpointsMatcher())
+					.with(authorizationServerConfigurer, (authorizationServer) ->
+							authorizationServer
+									.oidc(Customizer.withDefaults())
+					)
+					.authorizeHttpRequests((authorize) ->
+							authorize.anyRequest().authenticated()
+					);
+			// @formatter:on
+
+			return http.build();
+		}
+
+		@Bean
+		RegisteredClientRepository registeredClientRepository() {
+			RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+			return new InMemoryRegisteredClientRepository(registeredClient);
+		}
+
+		@Bean
+		OAuth2AuthorizationService authorizationService() {
+			return new InMemoryOAuth2AuthorizationService();
+		}
+
+		@Bean
+		JWKSource<SecurityContext> jwkSource() {
+			return new ImmutableJWKSet<>(new JWKSet(TestJwks.DEFAULT_RSA_JWK));
+		}
+
+		@Bean
+		JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
+			return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
+		}
+
+		@Bean
+		JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwkSource) {
+			return new NimbusJwtEncoder(jwkSource);
+		}
+
+		@Bean
+		AuthorizationServerSettings authorizationServerSettings() {
+			return AuthorizationServerSettings.builder().multipleIssuersAllowed(true).build();
+		}
+
+	}
+
+}

+ 48 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/context/TestAuthorizationServerContext.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020-2022 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.context;
+
+import java.util.function.Supplier;
+
+import org.springframework.lang.Nullable;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+
+/**
+ * @author Joe Grandja
+ */
+public class TestAuthorizationServerContext implements AuthorizationServerContext {
+
+	private final AuthorizationServerSettings authorizationServerSettings;
+
+	private final Supplier<String> issuerSupplier;
+
+	public TestAuthorizationServerContext(AuthorizationServerSettings authorizationServerSettings,
+			@Nullable Supplier<String> issuerSupplier) {
+		this.authorizationServerSettings = authorizationServerSettings;
+		this.issuerSupplier = issuerSupplier;
+	}
+
+	@Override
+	public String getIssuer() {
+		return (this.issuerSupplier != null) ? this.issuerSupplier.get() : getAuthorizationServerSettings().getIssuer();
+	}
+
+	@Override
+	public AuthorizationServerSettings getAuthorizationServerSettings() {
+		return this.authorizationServerSettings;
+	}
+
+}

+ 246 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2AuthorizationServerMetadataHttpMessageConverterTests.java

@@ -0,0 +1,246 @@
+/*
+ * 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.http.converter;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationServerMetadata;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OAuth2AuthorizationServerMetadataHttpMessageConverter}
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OAuth2AuthorizationServerMetadataHttpMessageConverterTests {
+
+	private final OAuth2AuthorizationServerMetadataHttpMessageConverter messageConverter = new OAuth2AuthorizationServerMetadataHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOAuth2AuthorizationServerMetadataThenTrue() {
+		assertThat(this.messageConverter.supports(OAuth2AuthorizationServerMetadata.class)).isTrue();
+	}
+
+	@Test
+	public void setAuthorizationServerMetadataParametersConverterWhenConverterIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setAuthorizationServerMetadataParametersConverter(null));
+	}
+
+	@Test
+	public void setAuthorizationServerMetadataConverterWhenConverterIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setAuthorizationServerMetadataConverter(null));
+	}
+
+	@Test
+	public void readInternalWhenRequiredParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String authorizationServerMetadataResponse = "{\n"
+				+ "		\"issuer\": \"https://example.com\",\n"
+				+ "		\"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n"
+				+ "		\"token_endpoint\": \"https://example.com/oauth2/token\",\n"
+				+ "		\"response_types_supported\": [\"code\"]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(authorizationServerMetadataResponse.getBytes(),
+				HttpStatus.OK);
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.messageConverter
+			.readInternal(OAuth2AuthorizationServerMetadata.class, response);
+
+		assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(new URL("https://example.com"));
+		assertThat(authorizationServerMetadata.getAuthorizationEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/authorize"));
+		assertThat(authorizationServerMetadata.getTokenEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/token"));
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getJwkSetUrl()).isNull();
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code");
+		assertThat(authorizationServerMetadata.getScopes()).isNull();
+		assertThat(authorizationServerMetadata.getGrantTypes()).isNull();
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpoint()).isNull();
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint()).isNull();
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods()).isNull();
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).isNull();
+	}
+
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String authorizationServerMetadataResponse = "{\n"
+				+ "		\"issuer\": \"https://example.com\",\n"
+				+ "		\"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n"
+				+ "		\"token_endpoint\": \"https://example.com/oauth2/token\",\n"
+				+ "		\"token_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n"
+				+ "		\"jwks_uri\": \"https://example.com/oauth2/jwks\",\n"
+				+ "		\"scopes_supported\": [\"openid\"],\n"
+				+ "		\"response_types_supported\": [\"code\"],\n"
+				+ "		\"grant_types_supported\": [\"authorization_code\", \"client_credentials\"],\n"
+				+ "		\"revocation_endpoint\": \"https://example.com/oauth2/revoke\",\n"
+				+ "		\"revocation_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n"
+				+ "		\"introspection_endpoint\": \"https://example.com/oauth2/introspect\",\n"
+				+ "		\"introspection_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n"
+				+ "		\"code_challenge_methods_supported\": [\"S256\"],\n"
+				+ "		\"custom_claim\": \"value\",\n"
+				+ "		\"custom_collection_claim\": [\"value1\", \"value2\"]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(authorizationServerMetadataResponse.getBytes(),
+				HttpStatus.OK);
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = this.messageConverter
+			.readInternal(OAuth2AuthorizationServerMetadata.class, response);
+
+		assertThat(authorizationServerMetadata.getClaims()).hasSize(15);
+		assertThat(authorizationServerMetadata.getIssuer()).isEqualTo(new URL("https://example.com"));
+		assertThat(authorizationServerMetadata.getAuthorizationEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/authorize"));
+		assertThat(authorizationServerMetadata.getTokenEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/token"));
+		assertThat(authorizationServerMetadata.getTokenEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(authorizationServerMetadata.getJwkSetUrl()).isEqualTo(new URL("https://example.com/oauth2/jwks"));
+		assertThat(authorizationServerMetadata.getScopes()).containsExactly("openid");
+		assertThat(authorizationServerMetadata.getResponseTypes()).containsExactly("code");
+		assertThat(authorizationServerMetadata.getGrantTypes()).containsExactlyInAnyOrder("authorization_code",
+				"client_credentials");
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/revoke"));
+		assertThat(authorizationServerMetadata.getTokenRevocationEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/introspect"));
+		assertThat(authorizationServerMetadata.getTokenIntrospectionEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(authorizationServerMetadata.getCodeChallengeMethods()).containsExactly("S256");
+		assertThat(authorizationServerMetadata.getClaimAsString("custom_claim")).isEqualTo("value");
+		assertThat(authorizationServerMetadata.getClaimAsStringList("custom_collection_claim"))
+			.containsExactlyInAnyOrder("value1", "value2");
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setAuthorizationServerMetadataConverter((source) -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OAuth2AuthorizationServerMetadata.class, response))
+			.withMessageContaining("An error occurred reading the OAuth 2.0 Authorization Server Metadata")
+			.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void readInternalWhenInvalidOAuth2AuthorizationServerMetadataThenThrowException() {
+		String authorizationServerMetadataResponse = "{ \"issuer\": null }";
+		MockClientHttpResponse response = new MockClientHttpResponse(authorizationServerMetadataResponse.getBytes(),
+				HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OAuth2AuthorizationServerMetadata.class, response))
+			.withMessageContaining("An error occurred reading the OAuth 2.0 Authorization Server Metadata")
+			.withMessageContaining("issuer cannot be null");
+	}
+
+	@Test
+	public void writeInternalWhenOAuth2AuthorizationServerMetadataThenSuccess() {
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.jwkSetUrl("https://example.com/oauth2/jwks")
+			.scope("openid")
+			.responseType("code")
+			.grantType("authorization_code")
+			.grantType("client_credentials")
+			.tokenRevocationEndpoint("https://example.com/oauth2/revoke")
+			.tokenRevocationEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.tokenIntrospectionEndpoint("https://example.com/oauth2/introspect")
+			.tokenIntrospectionEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.codeChallengeMethod("S256")
+			.claim("custom_claim", "value")
+			.claim("custom_collection_claim", Arrays.asList("value1", "value2"))
+			.build();
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(authorizationServerMetadata, outputMessage);
+
+		String authorizationServerMetadataResponse = outputMessage.getBodyAsString();
+		assertThat(authorizationServerMetadataResponse).contains("\"issuer\":\"https://example.com\"");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"authorization_endpoint\":\"https://example.com/oauth2/authorize\"");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"token_endpoint\":\"https://example.com/oauth2/token\"");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/jwks\"");
+		assertThat(authorizationServerMetadataResponse).contains("\"scopes_supported\":[\"openid\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"response_types_supported\":[\"code\"]");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"revocation_endpoint\":\"https://example.com/oauth2/revoke\"");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\"]");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"introspection_endpoint\":\"https://example.com/oauth2/introspect\"");
+		assertThat(authorizationServerMetadataResponse)
+			.contains("\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]");
+		assertThat(authorizationServerMetadataResponse).contains("\"custom_claim\":\"value\"");
+		assertThat(authorizationServerMetadataResponse).contains("\"custom_collection_claim\":[\"value1\",\"value2\"]");
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OAuth2AuthorizationServerMetadata, Map<String, Object>> failingConverter = (source) -> {
+			throw new RuntimeException(errorMessage);
+		};
+		this.messageConverter.setAuthorizationServerMetadataParametersConverter(failingConverter);
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+		OAuth2AuthorizationServerMetadata authorizationServerMetadata = OAuth2AuthorizationServerMetadata.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.responseType("code")
+			.build();
+
+		assertThatExceptionOfType(HttpMessageNotWritableException.class)
+			.isThrownBy(() -> this.messageConverter.writeInternal(authorizationServerMetadata, outputMessage))
+			.withMessageContaining("An error occurred writing the OAuth 2.0 Authorization Server Metadata")
+			.withMessageContaining(errorMessage);
+	}
+
+}

+ 174 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/http/converter/OAuth2TokenIntrospectionHttpMessageConverterTests.java

@@ -0,0 +1,174 @@
+/*
+ * Copyright 2020-2022 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.http.converter;
+
+import java.net.URL;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.OAuth2AccessToken.TokenType;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenIntrospection;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2TokenIntrospectionHttpMessageConverter}
+ *
+ * @author Gerardo Roza
+ * @author Joe Grandja
+ */
+public class OAuth2TokenIntrospectionHttpMessageConverterTests {
+
+	private final OAuth2TokenIntrospectionHttpMessageConverter messageConverter = new OAuth2TokenIntrospectionHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOAuth2TokenIntrospectionThenTrue() {
+		assertThat(this.messageConverter.supports(OAuth2TokenIntrospection.class)).isTrue();
+	}
+
+	@Test
+	public void setTokenIntrospectionParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setTokenIntrospectionParametersConverter(null));
+	}
+
+	@Test
+	public void setTokenIntrospectionConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setTokenIntrospectionConverter(null));
+	}
+
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String tokenIntrospectionResponseBody = "{\n"
+				+ "		\"active\": true,\n"
+				+ "		\"client_id\": \"clientId1\",\n"
+				+ "		\"username\": \"username1\",\n"
+				+ "		\"iat\": 1607633867,\n"
+				+ "		\"exp\": 1607637467,\n"
+				+ "		\"scope\": \"scope1 scope2\",\n"
+				+ "		\"token_type\": \"Bearer\",\n"
+				+ "		\"nbf\": 1607633867,\n"
+				+ "		\"sub\": \"subject1\",\n"
+				+ "		\"aud\": [\"audience1\", \"audience2\"],\n"
+				+ "		\"iss\": \"https://example.com/issuer1\",\n"
+				+ "		\"jti\": \"jwtId1\"\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(tokenIntrospectionResponseBody.getBytes(),
+				HttpStatus.OK);
+		OAuth2TokenIntrospection tokenIntrospectionResponse = this.messageConverter
+			.readInternal(OAuth2TokenIntrospection.class, response);
+
+		assertThat(tokenIntrospectionResponse.isActive()).isTrue();
+		assertThat(tokenIntrospectionResponse.getClientId()).isEqualTo("clientId1");
+		assertThat(tokenIntrospectionResponse.getUsername()).isEqualTo("username1");
+		assertThat(tokenIntrospectionResponse.getIssuedAt()).isEqualTo(Instant.ofEpochSecond(1607633867L));
+		assertThat(tokenIntrospectionResponse.getExpiresAt()).isEqualTo(Instant.ofEpochSecond(1607637467L));
+		assertThat(tokenIntrospectionResponse.getScopes())
+			.containsExactlyInAnyOrderElementsOf(Arrays.asList("scope1", "scope2"));
+		assertThat(tokenIntrospectionResponse.getTokenType()).isEqualTo("Bearer");
+		assertThat(tokenIntrospectionResponse.getNotBefore()).isEqualTo(Instant.ofEpochSecond(1607633867L));
+		assertThat(tokenIntrospectionResponse.getSubject()).isEqualTo("subject1");
+		assertThat(tokenIntrospectionResponse.getAudience())
+			.containsExactlyInAnyOrderElementsOf(Arrays.asList("audience1", "audience2"));
+		assertThat(tokenIntrospectionResponse.getIssuer()).isEqualTo(new URL("https://example.com/issuer1"));
+		assertThat(tokenIntrospectionResponse.getId()).isEqualTo("jwtId1");
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setTokenIntrospectionConverter((source) -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OAuth2TokenIntrospection.class, response))
+			.withMessageContaining("An error occurred reading the Token Introspection Response")
+			.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void writeInternalWhenTokenIntrospectionThenSuccess() {
+		// @formatter:off
+		OAuth2TokenIntrospection tokenClaims = OAuth2TokenIntrospection.builder(true)
+				.clientId("clientId1")
+				.username("username1")
+				.issuedAt(Instant.ofEpochSecond(1607633867))
+				.expiresAt(Instant.ofEpochSecond(1607637467))
+				.scope("scope1 scope2")
+				.tokenType(TokenType.BEARER.getValue())
+				.notBefore(Instant.ofEpochSecond(1607633867))
+				.subject("subject1")
+				.audience("audience1")
+				.audience("audience2")
+				.issuer("https://example.com/issuer1")
+				.id("jwtId1")
+				.build();
+		// @formatter:on
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(tokenClaims, outputMessage);
+
+		String tokenIntrospectionResponse = outputMessage.getBodyAsString();
+		assertThat(tokenIntrospectionResponse).contains("\"active\":true");
+		assertThat(tokenIntrospectionResponse).contains("\"client_id\":\"clientId1\"");
+		assertThat(tokenIntrospectionResponse).contains("\"username\":\"username1\"");
+		assertThat(tokenIntrospectionResponse).contains("\"iat\":1607633867");
+		assertThat(tokenIntrospectionResponse).contains("\"exp\":1607637467");
+		assertThat(tokenIntrospectionResponse).contains("\"scope\":\"scope1 scope2\"");
+		assertThat(tokenIntrospectionResponse).contains("\"token_type\":\"Bearer\"");
+		assertThat(tokenIntrospectionResponse).contains("\"nbf\":1607633867");
+		assertThat(tokenIntrospectionResponse).contains("\"sub\":\"subject1\"");
+		assertThat(tokenIntrospectionResponse).contains("\"aud\":[\"audience1\",\"audience2\"]");
+		assertThat(tokenIntrospectionResponse).contains("\"iss\":\"https://example.com/issuer1\"");
+		assertThat(tokenIntrospectionResponse).contains("\"jti\":\"jwtId1\"");
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowsException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OAuth2TokenIntrospection, Map<String, Object>> failingConverter = (source) -> {
+			throw new RuntimeException(errorMessage);
+		};
+		this.messageConverter.setTokenIntrospectionParametersConverter(failingConverter);
+
+		OAuth2TokenIntrospection tokenClaims = OAuth2TokenIntrospection.builder().build();
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatThrownBy(() -> this.messageConverter.writeInternal(tokenClaims, outputMessage))
+			.isInstanceOf(HttpMessageNotWritableException.class)
+			.hasMessageContaining("An error occurred writing the Token Introspection Response")
+			.hasMessageContaining(errorMessage);
+	}
+
+}

+ 87 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/jackson2/OAuth2AuthorizationServerJackson2ModuleTests.java

@@ -0,0 +1,87 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.jackson2;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.Map;
+import java.util.Set;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OAuth2AuthorizationServerJackson2Module}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OAuth2AuthorizationServerJackson2ModuleTests {
+
+	private static final TypeReference<Map<String, Object>> STRING_OBJECT_MAP = new TypeReference<>() {
+	};
+
+	private static final TypeReference<Set<String>> STRING_SET = new TypeReference<>() {
+	};
+
+	private static final TypeReference<String[]> STRING_ARRAY = new TypeReference<>() {
+	};
+
+	private ObjectMapper objectMapper;
+
+	@BeforeEach
+	public void setup() {
+		this.objectMapper = new ObjectMapper();
+		this.objectMapper.registerModule(new OAuth2AuthorizationServerJackson2Module());
+	}
+
+	@Test
+	public void readValueWhenUnmodifiableMapThenSuccess() throws Exception {
+		Map<String, Object> map = Collections.unmodifiableMap(new HashMap<>(Collections.singletonMap("key", "value")));
+		String json = this.objectMapper.writeValueAsString(map);
+		assertThat(this.objectMapper.readValue(json, STRING_OBJECT_MAP)).isEqualTo(map);
+	}
+
+	@Test
+	public void readValueWhenHashSetThenSuccess() throws Exception {
+		Set<String> set = new HashSet<>(Arrays.asList("one", "two"));
+		String json = this.objectMapper.writeValueAsString(set);
+		assertThat(this.objectMapper.readValue(json, STRING_SET)).isEqualTo(set);
+	}
+
+	// gh-457
+	@Test
+	public void readValueWhenLinkedHashSetThenSuccess() throws Exception {
+		Set<String> set = new LinkedHashSet<>(Arrays.asList("one", "two"));
+		String json = this.objectMapper.writeValueAsString(set);
+		assertThat(this.objectMapper.readValue(json, STRING_SET)).isEqualTo(set);
+	}
+
+	// gh-1666
+	@Test
+	public void readValueWhenStringArrayThenSuccess() throws Exception {
+		String[] array = new String[] { "one", "two" };
+		String json = this.objectMapper.writeValueAsString(array);
+		assertThat(this.objectMapper.readValue(json, STRING_ARRAY)).isEqualTo(array);
+	}
+
+}

+ 48 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/jackson2/TestingAuthenticationTokenMixin.java

@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020-2021 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.jackson2;
+
+import java.util.List;
+
+import com.fasterxml.jackson.annotation.JsonAutoDetect;
+import com.fasterxml.jackson.annotation.JsonCreator;
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonProperty;
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+
+/**
+ * This mixin class is used to serialize/deserialize {@link TestingAuthenticationToken}.
+ *
+ * @author Steve Riesenberg
+ * @since 0.1.2
+ * @see TestingAuthenticationToken
+ */
+@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS)
+@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY, getterVisibility = JsonAutoDetect.Visibility.NONE,
+		isGetterVisibility = JsonAutoDetect.Visibility.NONE)
+@JsonIgnoreProperties(value = { "authenticated" }, ignoreUnknown = true)
+public class TestingAuthenticationTokenMixin {
+
+	@JsonCreator
+	TestingAuthenticationTokenMixin(@JsonProperty("principal") Object principal,
+			@JsonProperty("credentials") Object credentials,
+			@JsonProperty("authorities") List<GrantedAuthority> authorities) {
+	}
+
+}

+ 440 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcClientRegistrationTests.java

@@ -0,0 +1,440 @@
+/*
+ * 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;
+
+import java.net.URL;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcClientRegistration}.
+ *
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ */
+public class OidcClientRegistrationTests {
+
+	// @formatter:off
+	private final OidcClientRegistration.Builder minimalBuilder =
+			OidcClientRegistration.builder()
+					.redirectUri("https://client.example.com");
+	// @formatter:on
+
+	@Test
+	public void buildWhenAllClaimsProvidedThenCreated() throws Exception {
+		// @formatter:off
+		Instant clientIdIssuedAt = Instant.now();
+		Instant clientSecretExpiresAt = clientIdIssuedAt.plus(30, ChronoUnit.DAYS);
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientId("client-id")
+				.clientIdIssuedAt(clientIdIssuedAt)
+				.clientSecret("client-secret")
+				.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())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.jwkSetUrl("https://client.example.com/jwks")
+				.idTokenSignedResponseAlgorithm(SignatureAlgorithm.RS256.getName())
+				.registrationAccessToken("registration-access-token")
+				.registrationClientUrl("https://auth-server.com/connect/register?client_id=1")
+				.claim("a-claim", "a-value")
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.getClientId()).isEqualTo("client-id");
+		assertThat(clientRegistration.getClientIdIssuedAt()).isEqualTo(clientIdIssuedAt);
+		assertThat(clientRegistration.getClientSecret()).isEqualTo("client-secret");
+		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");
+		assertThat(clientRegistration.getResponseTypes()).containsOnly("code");
+		assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("scope1", "scope2");
+		assertThat(clientRegistration.getJwkSetUrl()).isEqualTo(new URL("https://client.example.com/jwks"));
+		assertThat(clientRegistration.getIdTokenSignedResponseAlgorithm()).isEqualTo("RS256");
+		assertThat(clientRegistration.getRegistrationAccessToken()).isEqualTo("registration-access-token");
+		assertThat(clientRegistration.getRegistrationClientUrl().toString())
+			.isEqualTo("https://auth-server.com/connect/register?client_id=1");
+		assertThat(clientRegistration.getClaimAsString("a-claim")).isEqualTo("a-value");
+	}
+
+	@Test
+	public void buildWhenOnlyRequiredClaimsProvidedThenCreated() {
+		OidcClientRegistration clientRegistration = this.minimalBuilder.build();
+		assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com");
+	}
+
+	@Test
+	public void withClaimsWhenClaimsProvidedThenCreated() throws Exception {
+		Instant clientIdIssuedAt = Instant.now();
+		Instant clientSecretExpiresAt = clientIdIssuedAt.plus(30, ChronoUnit.DAYS);
+		HashMap<String, Object> claims = new HashMap<>();
+		claims.put(OidcClientMetadataClaimNames.CLIENT_ID, "client-id");
+		claims.put(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT, clientIdIssuedAt);
+		claims.put(OidcClientMetadataClaimNames.CLIENT_SECRET, "client-secret");
+		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(AuthorizationGrantType.AUTHORIZATION_CODE.getValue(),
+						AuthorizationGrantType.CLIENT_CREDENTIALS.getValue()));
+		claims.put(OidcClientMetadataClaimNames.RESPONSE_TYPES, Collections.singletonList("code"));
+		claims.put(OidcClientMetadataClaimNames.SCOPE, Arrays.asList("scope1", "scope2"));
+		claims.put(OidcClientMetadataClaimNames.JWKS_URI, "https://client.example.com/jwks");
+		claims.put(OidcClientMetadataClaimNames.ID_TOKEN_SIGNED_RESPONSE_ALG, SignatureAlgorithm.RS256.getName());
+		claims.put(OidcClientMetadataClaimNames.REGISTRATION_ACCESS_TOKEN, "registration-access-token");
+		claims.put(OidcClientMetadataClaimNames.REGISTRATION_CLIENT_URI,
+				"https://auth-server.com/connect/register?client_id=1");
+		claims.put("a-claim", "a-value");
+
+		OidcClientRegistration clientRegistration = OidcClientRegistration.withClaims(claims).build();
+
+		assertThat(clientRegistration.getClientId()).isEqualTo("client-id");
+		assertThat(clientRegistration.getClientIdIssuedAt()).isEqualTo(clientIdIssuedAt);
+		assertThat(clientRegistration.getClientSecret()).isEqualTo("client-secret");
+		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");
+		assertThat(clientRegistration.getResponseTypes()).containsOnly("code");
+		assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("scope1", "scope2");
+		assertThat(clientRegistration.getJwkSetUrl()).isEqualTo(new URL("https://client.example.com/jwks"));
+		assertThat(clientRegistration.getIdTokenSignedResponseAlgorithm()).isEqualTo("RS256");
+		assertThat(clientRegistration.getRegistrationAccessToken()).isEqualTo("registration-access-token");
+		assertThat(clientRegistration.getRegistrationClientUrl().toString())
+			.isEqualTo("https://auth-server.com/connect/register?client_id=1");
+		assertThat(clientRegistration.getClaimAsString("a-claim")).isEqualTo("a-value");
+	}
+
+	@Test
+	public void withClaimsWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OidcClientRegistration.withClaims(null))
+			.withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void withClaimsWhenEmptyThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OidcClientRegistration.withClaims(Collections.emptyMap()))
+			.withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void buildWhenMissingClientIdThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.clientIdIssuedAt(Instant.now());
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("client_id cannot be null");
+	}
+
+	@Test
+	public void buildWhenClientSecretAndMissingClientIdThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.clientSecret("client-secret");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("client_id cannot be null");
+	}
+
+	@Test
+	public void buildWhenClientIdIssuedAtNotInstantThenThrowIllegalArgumentException() {
+		// @formatter:off
+		OidcClientRegistration.Builder builder = this.minimalBuilder
+				.clientId("client-id")
+				.claim(OidcClientMetadataClaimNames.CLIENT_ID_ISSUED_AT, "clientIdIssuedAt");
+		// @formatter:on
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("client_id_issued_at must be of type Instant");
+	}
+
+	@Test
+	public void buildWhenMissingClientSecretThenThrowIllegalArgumentException() {
+		// @formatter:off
+		OidcClientRegistration.Builder builder = this.minimalBuilder
+				.clientId("client-id")
+				.clientIdIssuedAt(Instant.now())
+				.clientSecretExpiresAt(Instant.now().plus(30, ChronoUnit.DAYS));
+		// @formatter:on
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("client_secret cannot be null");
+	}
+
+	@Test
+	public void buildWhenClientSecretExpiresAtNotInstantThenThrowIllegalArgumentException() {
+		// @formatter:off
+		OidcClientRegistration.Builder builder = this.minimalBuilder
+				.clientId("client-id")
+				.clientIdIssuedAt(Instant.now())
+				.clientSecret("client-secret")
+				.claim(OidcClientMetadataClaimNames.CLIENT_SECRET_EXPIRES_AT, "clientSecretExpiresAt");
+		// @formatter:on
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("client_secret_expires_at must be of type Instant");
+	}
+
+	@Test
+	public void buildWhenMissingRedirectUrisThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = OidcClientRegistration.builder().clientName("client-name");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("redirect_uris cannot be null");
+	}
+
+	@Test
+	public void buildWhenRedirectUrisNotListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = OidcClientRegistration.builder()
+			.claim(OidcClientMetadataClaimNames.REDIRECT_URIS, "redirectUris");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("redirect_uris must be of type List");
+	}
+
+	@Test
+	public void buildWhenRedirectUrisEmptyListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = OidcClientRegistration.builder()
+			.claim(OidcClientMetadataClaimNames.REDIRECT_URIS, Collections.emptyList());
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("redirect_uris cannot be empty");
+	}
+
+	@Test
+	public void buildWhenRedirectUrisAddingOrRemovingThenCorrectValues() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.redirectUri("https://client1.example.com")
+				.redirectUris((redirectUris) -> {
+					redirectUris.clear();
+					redirectUris.add("https://client2.example.com");
+				})
+				.build();
+		// @formatter:on
+
+		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.claim(OidcClientMetadataClaimNames.GRANT_TYPES,
+				"grantTypes");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("grant_types must be of type List");
+	}
+
+	@Test
+	public void buildWhenGrantTypesEmptyListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.claim(OidcClientMetadataClaimNames.GRANT_TYPES,
+				Collections.emptyList());
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("grant_types cannot be empty");
+	}
+
+	@Test
+	public void buildWhenGrantTypesAddingOrRemovingThenCorrectValues() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.grantType("authorization_code")
+				.grantTypes((grantTypes) -> {
+					grantTypes.clear();
+					grantTypes.add("client_credentials");
+				})
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.getGrantTypes()).containsExactly("client_credentials");
+	}
+
+	@Test
+	public void buildWhenResponseTypesNotListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.claim(OidcClientMetadataClaimNames.RESPONSE_TYPES,
+				"responseTypes");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("response_types must be of type List");
+	}
+
+	@Test
+	public void buildWhenResponseTypesEmptyListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.claim(OidcClientMetadataClaimNames.RESPONSE_TYPES,
+				Collections.emptyList());
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("response_types cannot be empty");
+	}
+
+	@Test
+	public void buildWhenResponseTypesAddingOrRemovingThenCorrectValues() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.responseType("token")
+				.responseTypes((responseTypes) -> {
+					responseTypes.clear();
+					responseTypes.add("code");
+				})
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.getResponseTypes()).containsExactly("code");
+	}
+
+	@Test
+	public void buildWhenScopesNotListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.claim(OidcClientMetadataClaimNames.SCOPE,
+				"scopes");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("scope must be of type List");
+	}
+
+	@Test
+	public void buildWhenScopesEmptyListThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.claim(OidcClientMetadataClaimNames.SCOPE,
+				Collections.emptyList());
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("scope cannot be empty");
+	}
+
+	@Test
+	public void buildWhenScopesAddingOrRemovingThenCorrectValues() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.scope("should-be-removed")
+				.scopes((scopes) -> {
+					scopes.clear();
+					scopes.add("scope1");
+				})
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.getScopes()).containsExactly("scope1");
+	}
+
+	@Test
+	public void buildWhenJwksUriNotUrlThenThrowIllegalArgumentException() {
+		OidcClientRegistration.Builder builder = this.minimalBuilder.claim(OidcClientMetadataClaimNames.JWKS_URI,
+				"not an url");
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("jwksUri must be a valid URL");
+	}
+
+	@Test
+	public void claimWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OidcClientRegistration.builder().claim(null, "claim-value"))
+			.withMessage("name cannot be empty");
+	}
+
+	@Test
+	public void claimWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OidcClientRegistration.builder().claim("claim-name", null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void claimsWhenRemovingClaimThenNotPresent() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.claim("claim-name", "claim-value")
+				.claims((claims) -> claims.remove("claim-name"))
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.hasClaim("claim-name")).isFalse();
+	}
+
+	@Test
+	public void claimsWhenAddingClaimThenPresent() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = this.minimalBuilder
+				.claim("claim-name", "claim-value")
+				.build();
+		// @formatter:on
+
+		assertThat(clientRegistration.hasClaim("claim-name")).isTrue();
+	}
+
+}

+ 525 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/OidcProviderConfigurationTests.java

@@ -0,0 +1,525 @@
+/*
+ * 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;
+
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcProviderConfiguration}.
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcProviderConfigurationTests {
+
+	private final OidcProviderConfiguration.Builder minimalConfigurationBuilder = OidcProviderConfiguration.builder()
+		.issuer("https://example.com")
+		.authorizationEndpoint("https://example.com/oauth2/authorize")
+		.tokenEndpoint("https://example.com/oauth2/token")
+		.jwkSetUrl("https://example.com/oauth2/jwks")
+		.scope("openid")
+		.responseType("code")
+		.subjectType("public")
+		.idTokenSigningAlgorithm("RS256");
+
+	@Test
+	public void buildWhenAllRequiredClaimsAndAdditionalClaimsThenCreated() {
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.jwkSetUrl("https://example.com/oauth2/jwks")
+			.scope("openid")
+			.responseType("code")
+			.grantType("authorization_code")
+			.grantType("client_credentials")
+			.subjectType("public")
+			.idTokenSigningAlgorithm("RS256")
+			.userInfoEndpoint("https://example.com/userinfo")
+			.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.clientRegistrationEndpoint("https://example.com/connect/register")
+			.endSessionEndpoint("https://example.com/connect/logout")
+			.claim("a-claim", "a-value")
+			.build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code",
+				"client_credentials");
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256");
+		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/userinfo"));
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(providerConfiguration.getClientRegistrationEndpoint())
+			.isEqualTo(url("https://example.com/connect/register"));
+		assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/connect/logout"));
+		assertThat(providerConfiguration.<String>getClaim("a-claim")).isEqualTo("a-value");
+	}
+
+	@Test
+	public void buildWhenOnlyRequiredClaimsThenCreated() {
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.jwkSetUrl("https://example.com/oauth2/jwks")
+			.scope("openid")
+			.responseType("code")
+			.subjectType("public")
+			.idTokenSigningAlgorithm("RS256")
+			.build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+	}
+
+	@Test
+	public void buildWhenClaimsProvidedThenCreated() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(OidcProviderMetadataClaimNames.ISSUER, "https://example.com");
+		claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "https://example.com/oauth2/authorize");
+		claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "https://example.com/oauth2/token");
+		claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "https://example.com/oauth2/jwks");
+		claims.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid"));
+		claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code"));
+		claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.singletonList("public"));
+		claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED,
+				Collections.singletonList("RS256"));
+		claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, "https://example.com/userinfo");
+		claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, "https://example.com/connect/register");
+		claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, "https://example.com/connect/logout");
+		claims.put("some-claim", "some-value");
+
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256");
+		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/userinfo"));
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(providerConfiguration.getClientRegistrationEndpoint())
+			.isEqualTo(url("https://example.com/connect/register"));
+		assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/connect/logout"));
+		assertThat(providerConfiguration.<String>getClaim("some-claim")).isEqualTo("some-value");
+	}
+
+	@Test
+	public void buildWhenClaimsProvidedWithUrlsThenCreated() {
+		Map<String, Object> claims = new HashMap<>();
+		claims.put(OidcProviderMetadataClaimNames.ISSUER, url("https://example.com"));
+		claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, url("https://example.com/oauth2/authorize"));
+		claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, url("https://example.com/oauth2/token"));
+		claims.put(OidcProviderMetadataClaimNames.JWKS_URI, url("https://example.com/oauth2/jwks"));
+		claims.put(OidcProviderMetadataClaimNames.SCOPES_SUPPORTED, Collections.singletonList("openid"));
+		claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.singletonList("code"));
+		claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.singletonList("public"));
+		claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED,
+				Collections.singletonList("RS256"));
+		claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, url("https://example.com/userinfo"));
+		claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, url("https://example.com/connect/register"));
+		claims.put(OidcProviderMetadataClaimNames.END_SESSION_ENDPOINT, url("https://example.com/connect/logout"));
+		claims.put("some-claim", "some-value");
+
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.withClaims(claims).build();
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(url("https://example.com"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint())
+			.isEqualTo(url("https://example.com/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(url("https://example.com/oauth2/token"));
+		assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(url("https://example.com/oauth2/jwks"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256");
+		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(url("https://example.com/userinfo"));
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+		assertThat(providerConfiguration.getClientRegistrationEndpoint())
+			.isEqualTo(url("https://example.com/connect/register"));
+		assertThat(providerConfiguration.getEndSessionEndpoint()).isEqualTo(url("https://example.com/connect/logout"));
+		assertThat(providerConfiguration.<String>getClaim("some-claim")).isEqualTo("some-value");
+	}
+
+	@Test
+	public void withClaimsWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OidcProviderConfiguration.withClaims(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void withClaimsWhenMissingRequiredClaimsThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OidcProviderConfiguration.withClaims(Collections.emptyMap()))
+			.withMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void buildWhenCalledTwiceThenGeneratesTwoConfigurations() {
+		OidcProviderConfiguration first = this.minimalConfigurationBuilder.grantType("client_credentials").build();
+
+		OidcProviderConfiguration second = this.minimalConfigurationBuilder.claims((claims) -> {
+			List<String> newGrantTypes = new ArrayList<>();
+			newGrantTypes.add("authorization_code");
+			newGrantTypes.add("custom_grant");
+			claims.put(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED, newGrantTypes);
+		}).build();
+
+		assertThat(first.getGrantTypes()).containsExactly("client_credentials");
+		assertThat(second.getGrantTypes()).containsExactlyInAnyOrder("authorization_code", "custom_grant");
+	}
+
+	@Test
+	public void buildWhenMissingIssuerThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.ISSUER));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("issuer cannot be null");
+	}
+
+	@Test
+	public void buildWhenIssuerNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.ISSUER, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("issuer must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingAuthorizationEndpointThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("authorizationEndpoint cannot be null");
+	}
+
+	@Test
+	public void buildWhenAuthorizationEndpointNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.AUTHORIZATION_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("authorizationEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingTokenEndpointThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("tokenEndpoint cannot be null");
+	}
+
+	@Test
+	public void buildWhenTokenEndpointNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.TOKEN_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("tokenEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingJwksUriThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.JWKS_URI));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("jwksUri cannot be null");
+	}
+
+	@Test
+	public void buildWhenJwksUriNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.JWKS_URI, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageStartingWith("jwksUri must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenMissingResponseTypesThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("responseTypes cannot be null");
+	}
+
+	@Test
+	public void buildWhenResponseTypesNotListThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder.claims((claims) -> {
+			claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED);
+			claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, "code");
+		});
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageContaining("responseTypes must be of type List");
+	}
+
+	@Test
+	public void buildWhenResponseTypesEmptyListThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder.claims((claims) -> {
+			claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED);
+			claims.put(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED, Collections.emptyList());
+		});
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageContaining("responseTypes cannot be empty");
+	}
+
+	@Test
+	public void buildWhenMissingSubjectTypesThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build).withMessage("subjectTypes cannot be null");
+	}
+
+	@Test
+	public void buildWhenSubjectTypesNotListThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder.claims((claims) -> {
+			claims.remove(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED);
+			claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, "public");
+		});
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageContaining("subjectTypes must be of type List");
+	}
+
+	@Test
+	public void buildWhenSubjectTypesEmptyListThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder.claims((claims) -> {
+			claims.remove(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED);
+			claims.put(OidcProviderMetadataClaimNames.SUBJECT_TYPES_SUPPORTED, Collections.emptyList());
+		});
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageContaining("subjectTypes cannot be empty");
+	}
+
+	@Test
+	public void buildWhenMissingIdTokenSigningAlgorithmsThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("idTokenSigningAlgorithms cannot be null");
+	}
+
+	@Test
+	public void buildWhenIdTokenSigningAlgorithmsNotListThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder.claims((claims) -> {
+			claims.remove(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED);
+			claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, "RS256");
+		});
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageContaining("idTokenSigningAlgorithms must be of type List");
+	}
+
+	@Test
+	public void buildWhenIdTokenSigningAlgorithmsEmptyListThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder.claims((claims) -> {
+			claims.remove(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED);
+			claims.put(OidcProviderMetadataClaimNames.ID_TOKEN_SIGNING_ALG_VALUES_SUPPORTED, Collections.emptyList());
+		});
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessageContaining("idTokenSigningAlgorithms cannot be empty");
+	}
+
+	@Test
+	public void buildWhenUserInfoEndpointNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.USER_INFO_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.withMessage("userInfoEndpoint must be a valid URL");
+	}
+
+	@Test
+	public void buildWhenClientRegistrationEndpointNotUrlThenThrowIllegalArgumentException() {
+		OidcProviderConfiguration.Builder builder = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.put(OidcProviderMetadataClaimNames.REGISTRATION_ENDPOINT, "not an url"));
+
+		assertThatIllegalArgumentException().isThrownBy(builder::build)
+			.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.responseType("should-be-removed")
+			.responseTypes((responseTypes) -> {
+				responseTypes.clear();
+				responseTypes.add("some-response-type");
+			})
+			.build();
+
+		assertThat(configuration.getResponseTypes()).containsExactly("some-response-type");
+	}
+
+	@Test
+	public void responseTypesWhenNotPresentAndAddingThenCorrectValues() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.RESPONSE_TYPES_SUPPORTED))
+			.responseTypes((responseTypes) -> responseTypes.add("some-response-type"))
+			.build();
+
+		assertThat(configuration.getResponseTypes()).containsExactly("some-response-type");
+	}
+
+	@Test
+	public void subjectTypesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder.subjectType("should-be-removed")
+			.subjectTypes((subjectTypes) -> {
+				subjectTypes.clear();
+				subjectTypes.add("some-subject-type");
+			})
+			.build();
+
+		assertThat(configuration.getSubjectTypes()).containsExactly("some-subject-type");
+	}
+
+	@Test
+	public void idTokenSigningAlgorithmsWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder
+			.idTokenSigningAlgorithm("should-be-removed")
+			.idTokenSigningAlgorithms((signingAlgorithms) -> {
+				signingAlgorithms.clear();
+				signingAlgorithms.add("ES256");
+			})
+			.build();
+
+		assertThat(configuration.getIdTokenSigningAlgorithms()).containsExactly("ES256");
+	}
+
+	@Test
+	public void scopesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder.scope("should-be-removed")
+			.scopes((scopes) -> {
+				scopes.clear();
+				scopes.add("some-scope");
+			})
+			.build();
+
+		assertThat(configuration.getScopes()).containsExactly("some-scope");
+	}
+
+	@Test
+	public void grantTypesWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder.grantType("should-be-removed")
+			.grantTypes((grantTypes) -> {
+				grantTypes.clear();
+				grantTypes.add("some-grant-type");
+			})
+			.build();
+
+		assertThat(configuration.getGrantTypes()).containsExactly("some-grant-type");
+	}
+
+	@Test
+	public void tokenEndpointAuthenticationMethodsWhenAddingOrRemovingThenCorrectValues() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder
+			.tokenEndpointAuthenticationMethod("should-be-removed")
+			.tokenEndpointAuthenticationMethods((authMethods) -> {
+				authMethods.clear();
+				authMethods.add("some-authentication-method");
+			})
+			.build();
+
+		assertThat(configuration.getTokenEndpointAuthenticationMethods()).containsExactly("some-authentication-method");
+	}
+
+	@Test
+	public void claimWhenNameIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> OidcProviderConfiguration.builder().claim(null, "value"))
+			.withMessage("name cannot be empty");
+	}
+
+	@Test
+	public void claimWhenValueIsNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> OidcProviderConfiguration.builder().claim("claim-name", null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void claimsWhenRemovingClaimThenNotPresent() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder.grantType("some-grant-type")
+			.claims((claims) -> claims.remove(OidcProviderMetadataClaimNames.GRANT_TYPES_SUPPORTED))
+			.build();
+		assertThat(configuration.getGrantTypes()).isNull();
+	}
+
+	@Test
+	public void claimsWhenAddingClaimThenPresent() {
+		OidcProviderConfiguration configuration = this.minimalConfigurationBuilder.claim("claim-name", "claim-value")
+			.build();
+		assertThat(configuration.hasClaim("claim-name")).isTrue();
+	}
+
+	private static URL url(String urlString) {
+		try {
+			return new URL(urlString);
+		}
+		catch (Exception ex) {
+			throw new IllegalArgumentException("urlString must be a valid URL and valid URI");
+		}
+	}
+
+}

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

@@ -0,0 +1,420 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.oidc.authentication;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+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.authority.AuthorityUtils;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.TestJwsHeaders;
+import org.springframework.security.oauth2.jwt.TestJwtClaimsSets;
+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.AuthorizationServerContext;
+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.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.util.UriComponentsBuilder;
+
+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.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OidcClientConfigurationAuthenticationProvider}.
+ *
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ */
+public class OidcClientConfigurationAuthenticationProviderTests {
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	private OidcClientConfigurationAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(this.authorizationServerSettings, null));
+		this.authenticationProvider = new OidcClientConfigurationAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService);
+	}
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientConfigurationAuthenticationProvider(null, this.authorizationService))
+			.withMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientConfigurationAuthenticationProvider(this.registeredClientRepository, null))
+			.withMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOidcClientRegistrationAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OidcClientRegistrationAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void setClientRegistrationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authenticationProvider.setClientRegistrationConverter(null))
+			.withMessage("clientRegistrationConverter cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotOAuth2TokenAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, "client-id");
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(createJwtClientConfiguration());
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, "client-id");
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientConfiguration();
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, "client-id");
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+		verify(this.authorizationService).findByToken(eq(jwt.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotActiveThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientConfiguration();
+		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())
+			.invalidate(jwtAccessToken)
+			.build();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, registeredClient.getClientId());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwt(Collections.singleton("unauthorized.scope"));
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_unauthorized.scope"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, registeredClient.getClientId());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INSUFFICIENT_SCOPE);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenContainsRequiredScopeAndAdditionalScopeThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwt(new HashSet<>(Arrays.asList("client.read", "scope1")));
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read", "SCOPE_scope1"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, registeredClient.getClientId());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenRegisteredClientNotFoundThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientConfiguration();
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, registeredClient.getClientId());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+		verify(this.registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+	}
+
+	@Test
+	public void authenticateWhenClientIdNotEqualToAuthorizedClientThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientConfiguration();
+		OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE));
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		RegisteredClient authorizedRegisteredClient = TestRegisteredClients.registeredClient()
+			.id("registration-2")
+			.clientId("client-2")
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(authorizedRegisteredClient, jwtAccessToken, jwt.getClaims())
+			.build();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, registeredClient.getClientId());
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_CLIENT);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+		verify(this.registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenReturnClientRegistration() {
+		Jwt jwt = createJwtClientConfiguration();
+		OAuth2AccessToken jwtAccessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER,
+				jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getClaim(OAuth2ParameterNames.SCOPE));
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.clientAuthenticationMethods((clientAuthenticationMethods) -> {
+				clientAuthenticationMethods.clear();
+				clientAuthenticationMethods.add(ClientAuthenticationMethod.PRIVATE_KEY_JWT);
+			})
+			.clientSettings(ClientSettings.builder()
+				.tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS512)
+				.jwkSetUrl("https://client.example.com/jwks")
+				.build())
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, jwtAccessToken, jwt.getClaims())
+			.build();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findByClientId(eq(registeredClient.getClientId())))
+			.willReturn(registeredClient);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, registeredClient.getClientId());
+
+		OidcClientRegistrationAuthenticationToken authenticationResult = (OidcClientRegistrationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+		verify(this.registeredClientRepository).findByClientId(eq(registeredClient.getClientId()));
+
+		// verify that the "registration" access token is not invalidated after it is used
+		verify(this.authorizationService, never()).save(eq(authorization));
+		assertThat(authorization.getAccessToken().isInvalidated()).isFalse();
+
+		OidcClientRegistration clientRegistrationResult = authenticationResult.getClientRegistration();
+		assertThat(clientRegistrationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(clientRegistrationResult.getClientIdIssuedAt()).isEqualTo(registeredClient.getClientIdIssuedAt());
+		assertThat(clientRegistrationResult.getClientSecret()).isEqualTo(registeredClient.getClientSecret());
+		assertThat(clientRegistrationResult.getClientSecretExpiresAt())
+			.isEqualTo(registeredClient.getClientSecretExpiresAt());
+		assertThat(clientRegistrationResult.getClientName()).isEqualTo(registeredClient.getClientName());
+		assertThat(clientRegistrationResult.getRedirectUris())
+			.containsExactlyInAnyOrderElementsOf(registeredClient.getRedirectUris());
+
+		List<String> grantTypes = new ArrayList<>();
+		registeredClient.getAuthorizationGrantTypes()
+			.forEach((authorizationGrantType) -> grantTypes.add(authorizationGrantType.getValue()));
+		assertThat(clientRegistrationResult.getGrantTypes()).containsExactlyInAnyOrderElementsOf(grantTypes);
+
+		assertThat(clientRegistrationResult.getResponseTypes())
+			.containsExactly(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistrationResult.getScopes())
+			.containsExactlyInAnyOrderElementsOf(registeredClient.getScopes());
+		assertThat(clientRegistrationResult.getTokenEndpointAuthenticationMethod())
+			.isEqualTo(registeredClient.getClientAuthenticationMethods().iterator().next().getValue());
+		assertThat(clientRegistrationResult.getTokenEndpointAuthenticationSigningAlgorithm())
+			.isEqualTo(registeredClient.getClientSettings().getTokenEndpointAuthenticationSigningAlgorithm().getName());
+		assertThat(clientRegistrationResult.getJwkSetUrl().toString())
+			.isEqualTo(registeredClient.getClientSettings().getJwkSetUrl());
+		assertThat(clientRegistrationResult.getIdTokenSignedResponseAlgorithm())
+			.isEqualTo(registeredClient.getTokenSettings().getIdTokenSignatureAlgorithm().getName());
+
+		AuthorizationServerContext authorizationServerContext = AuthorizationServerContextHolder.getContext();
+		String expectedRegistrationClientUrl = UriComponentsBuilder
+			.fromUriString(authorizationServerContext.getIssuer())
+			.path(authorizationServerContext.getAuthorizationServerSettings().getOidcClientRegistrationEndpoint())
+			.queryParam(OAuth2ParameterNames.CLIENT_ID, registeredClient.getClientId())
+			.toUriString();
+
+		assertThat(clientRegistrationResult.getRegistrationClientUrl().toString())
+			.isEqualTo(expectedRegistrationClientUrl);
+		assertThat(clientRegistrationResult.getRegistrationAccessToken()).isNull();
+	}
+
+	private static Jwt createJwtClientConfiguration() {
+		return createJwt(Collections.singleton("client.read"));
+	}
+
+	private static Jwt createJwt(Set<String> scopes) {
+		// @formatter:off
+		JwsHeader jwsHeader = TestJwsHeaders.jwsHeader()
+				.build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet()
+				.claim(OAuth2ParameterNames.SCOPE, scopes)
+				.build();
+		Jwt jwt = Jwt.withTokenValue("jwt-access-token")
+				.headers((headers) -> headers.putAll(jwsHeader.getHeaders()))
+				.claims((claims) -> claims.putAll(jwtClaimsSet.getClaims()))
+				.build();
+		// @formatter:on
+		return jwt;
+	}
+
+}

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

@@ -0,0 +1,791 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.oidc.authentication;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+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.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.crypto.password.NoOpPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.TestJwsHeaders;
+import org.springframework.security.oauth2.jwt.TestJwtClaimsSets;
+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.AuthorizationServerContext;
+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.oidc.OidcClientMetadataClaimNames;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.token.JwtGenerator;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenContext;
+import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenGenerator;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.reset;
+import static org.mockito.Mockito.spy;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OidcClientRegistrationAuthenticationProvider}.
+ *
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ */
+public class OidcClientRegistrationAuthenticationProviderTests {
+
+	private RegisteredClientRepository registeredClientRepository;
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private JwtEncoder jwtEncoder;
+
+	private OAuth2TokenGenerator<?> tokenGenerator;
+
+	private PasswordEncoder passwordEncoder;
+
+	private AuthorizationServerSettings authorizationServerSettings;
+
+	private OidcClientRegistrationAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.registeredClientRepository = mock(RegisteredClientRepository.class);
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.jwtEncoder = mock(JwtEncoder.class);
+		JwtGenerator jwtGenerator = new JwtGenerator(this.jwtEncoder);
+		this.tokenGenerator = spy(new OAuth2TokenGenerator<Jwt>() {
+			@Override
+			public Jwt generate(OAuth2TokenContext context) {
+				return jwtGenerator.generate(context);
+			}
+		});
+		this.passwordEncoder = spy(new PasswordEncoder() {
+			@Override
+			public String encode(CharSequence rawPassword) {
+				return NoOpPasswordEncoder.getInstance().encode(rawPassword);
+			}
+
+			@Override
+			public boolean matches(CharSequence rawPassword, String encodedPassword) {
+				return NoOpPasswordEncoder.getInstance().matches(rawPassword, encodedPassword);
+			}
+		});
+		this.authorizationServerSettings = AuthorizationServerSettings.builder().issuer("https://provider.com").build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(this.authorizationServerSettings, null));
+		this.authenticationProvider = new OidcClientRegistrationAuthenticationProvider(this.registeredClientRepository,
+				this.authorizationService, this.tokenGenerator);
+		this.authenticationProvider.setPasswordEncoder(this.passwordEncoder);
+	}
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void constructorWhenRegisteredClientRepositoryNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationAuthenticationProvider(null, this.authorizationService,
+					this.tokenGenerator))
+			.withMessage("registeredClientRepository cannot be null");
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationAuthenticationProvider(this.registeredClientRepository, null,
+					this.tokenGenerator))
+			.withMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationAuthenticationProvider(this.registeredClientRepository,
+					this.authorizationService, null))
+			.withMessage("tokenGenerator cannot be null");
+	}
+
+	@Test
+	public void setRegisteredClientConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authenticationProvider.setRegisteredClientConverter(null))
+			.withMessage("registeredClientConverter cannot be null");
+	}
+
+	@Test
+	public void setClientRegistrationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.authenticationProvider.setClientRegistrationConverter(null))
+			.withMessage("clientRegistrationConverter cannot be null");
+	}
+
+	@Test
+	public void setPasswordEncoderWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setPasswordEncoder(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("passwordEncoder cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOidcClientRegistrationAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OidcClientRegistrationAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotOAuth2TokenAuthenticationTokenThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+			.redirectUri("https://client.example.com")
+			.build();
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(createJwtClientRegistration());
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+			.redirectUri("https://client.example.com")
+			.build();
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwtClientRegistration();
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+			.redirectUri("https://client.example.com")
+			.build();
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+		verify(this.authorizationService).findByToken(eq(jwt.getTokenValue()), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotActiveThenThrowOAuth2AuthenticationException() {
+		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())
+			.invalidate(jwtAccessToken)
+			.build();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+			.redirectUri("https://client.example.com")
+			.build();
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwt(Collections.singleton("unauthorized.scope"));
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_unauthorized.scope"));
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+			.redirectUri("https://client.example.com")
+			.build();
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INSUFFICIENT_SCOPE);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenContainsRequiredScopeAndAdditionalScopeThenThrowOAuth2AuthenticationException() {
+		Jwt jwt = createJwt(new HashSet<>(Arrays.asList("client.create", "scope1")));
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create", "SCOPE_scope1"));
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+			.redirectUri("https://client.example.com")
+			.build();
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenInvalidRedirectUriThenThrowOAuth2AuthenticationException() {
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("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(OAuth2ErrorCodes.INVALID_REDIRECT_URI);
+				assertThat(error.getDescription()).contains(OidcClientMetadataClaimNames.REDIRECT_URIS);
+			});
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenRedirectUriContainsFragmentThenThrowOAuth2AuthenticationException() {
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("https://client.example.com#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(OAuth2ErrorCodes.INVALID_REDIRECT_URI);
+				assertThat(error.getDescription()).contains(OidcClientMetadataClaimNames.REDIRECT_URIS);
+			});
+		verify(this.authorizationService).findByToken(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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(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();
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration.Builder builder = OidcClientRegistration.builder()
+				.redirectUri("https://client.example.com");
+		// @formatter:on
+
+		String invalidClientMetadataErrorCode = "invalid_client_metadata";
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+				.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256.getName());
+		assertWhenClientRegistrationRequestInvalidThenThrowOAuth2AuthenticationException(principal, builder.build(),
+				invalidClientMetadataErrorCode, OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
+		// @formatter:on
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue())
+				.tokenEndpointAuthenticationSigningAlgorithm("none");
+		assertWhenClientRegistrationRequestInvalidThenThrowOAuth2AuthenticationException(principal, builder.build(),
+				invalidClientMetadataErrorCode, OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
+		// @formatter:on
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue())
+				.tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS256.getName());
+		assertWhenClientRegistrationRequestInvalidThenThrowOAuth2AuthenticationException(principal, builder.build(),
+				invalidClientMetadataErrorCode, OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
+		// @formatter:on
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue())
+				.jwkSetUrl("https://client.example.com/jwks")
+				.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256.getName());
+		assertWhenClientRegistrationRequestInvalidThenThrowOAuth2AuthenticationException(principal, builder.build(),
+				invalidClientMetadataErrorCode, OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
+		// @formatter:on
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue())
+				.tokenEndpointAuthenticationSigningAlgorithm(SignatureAlgorithm.RS256.getName());
+		assertWhenClientRegistrationRequestInvalidThenThrowOAuth2AuthenticationException(principal, builder.build(),
+				invalidClientMetadataErrorCode, OidcClientMetadataClaimNames.TOKEN_ENDPOINT_AUTH_METHOD);
+		// @formatter:on
+	}
+
+	private void assertWhenClientRegistrationRequestInvalidThenThrowOAuth2AuthenticationException(
+			Authentication principal, OidcClientRegistration clientRegistration, String errorCode,
+			String errorDescription) {
+
+		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(errorCode);
+				assertThat(error.getDescription()).contains(errorDescription);
+			});
+	}
+
+	@Test
+	public void authenticateWhenTokenEndpointAuthenticationSigningAlgorithmNotProvidedThenDefaults() {
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+		given(this.jwtEncoder.encode(any())).willReturn(createJwtClientConfiguration());
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration.Builder builder = OidcClientRegistration.builder()
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.redirectUri("https://client.example.com")
+				.scope("scope1");
+		// @formatter:on
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_JWT.getValue());
+		// @formatter:on
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, builder.build());
+		OidcClientRegistrationAuthenticationToken authenticationResult = (OidcClientRegistrationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getClientRegistration().getTokenEndpointAuthenticationSigningAlgorithm())
+			.isEqualTo(MacAlgorithm.HS256.getName());
+		assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNotNull();
+		verify(this.passwordEncoder).encode(any());
+		reset(this.passwordEncoder);
+
+		// @formatter:off
+		builder
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.PRIVATE_KEY_JWT.getValue())
+				.jwkSetUrl("https://client.example.com/jwks");
+		// @formatter:on
+		authentication = new OidcClientRegistrationAuthenticationToken(principal, builder.build());
+		authenticationResult = (OidcClientRegistrationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+		assertThat(authenticationResult.getClientRegistration().getTokenEndpointAuthenticationSigningAlgorithm())
+			.isEqualTo(SignatureAlgorithm.RS256.getName());
+		assertThat(authenticationResult.getClientRegistration().getClientSecret()).isNull();
+		verifyNoInteractions(this.passwordEncoder);
+	}
+
+	@Test
+	public void authenticateWhenRegistrationAccessTokenNotGeneratedThenThrowOAuth2AuthenticationException() {
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		doReturn(null).when(this.tokenGenerator).generate(any());
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.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(OAuth2ErrorCodes.SERVER_ERROR);
+				assertThat(error.getDescription())
+					.contains("The token generator failed to generate the registration access token.");
+			});
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenReturnClientRegistration() {
+		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();
+		given(this.authorizationService.findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+		given(this.jwtEncoder.encode(any())).willReturn(createJwtClientConfiguration());
+
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+		// @formatter:off
+		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")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				principal, clientRegistration);
+		OidcClientRegistrationAuthenticationToken authenticationResult = (OidcClientRegistrationAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		ArgumentCaptor<RegisteredClient> registeredClientCaptor = ArgumentCaptor.forClass(RegisteredClient.class);
+		ArgumentCaptor<OAuth2Authorization> authorizationCaptor = ArgumentCaptor.forClass(OAuth2Authorization.class);
+
+		verify(this.authorizationService).findByToken(eq(jwtAccessToken.getTokenValue()),
+				eq(OAuth2TokenType.ACCESS_TOKEN));
+		verify(this.registeredClientRepository).save(registeredClientCaptor.capture());
+		verify(this.authorizationService, times(2)).save(authorizationCaptor.capture());
+		verify(this.jwtEncoder).encode(any());
+		verify(this.passwordEncoder).encode(any());
+
+		// assert "registration" access token, which should be used for subsequent calls
+		// to client configuration endpoint
+		OAuth2Authorization authorizationResult = authorizationCaptor.getAllValues().get(0);
+		assertThat(authorizationResult.getAccessToken().getToken().getScopes()).containsExactly("client.read");
+		assertThat(authorizationResult.getAccessToken().isActive()).isTrue();
+		assertThat(authorizationResult.getRefreshToken()).isNull();
+
+		// assert "initial" access token is invalidated
+		authorizationResult = authorizationCaptor.getAllValues().get(1);
+		assertThat(authorizationResult.getAccessToken().isInvalidated()).isTrue();
+		if (authorizationResult.getRefreshToken() != null) {
+			assertThat(authorizationResult.getRefreshToken().isInvalidated()).isTrue();
+		}
+
+		RegisteredClient registeredClientResult = registeredClientCaptor.getValue();
+		assertThat(registeredClientResult.getId()).isNotNull();
+		assertThat(registeredClientResult.getClientId()).isNotNull();
+		assertThat(registeredClientResult.getClientIdIssuedAt()).isNotNull();
+		assertThat(registeredClientResult.getClientSecret()).isNotNull();
+		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");
+		assertThat(registeredClientResult.getClientSettings().isRequireProofKey()).isTrue();
+		assertThat(registeredClientResult.getClientSettings().isRequireAuthorizationConsent()).isTrue();
+		assertThat(registeredClientResult.getTokenSettings().getIdTokenSignatureAlgorithm())
+			.isEqualTo(SignatureAlgorithm.RS256);
+
+		OidcClientRegistration clientRegistrationResult = authenticationResult.getClientRegistration();
+		assertThat(clientRegistrationResult.getClientId()).isEqualTo(registeredClientResult.getClientId());
+		assertThat(clientRegistrationResult.getClientIdIssuedAt())
+			.isEqualTo(registeredClientResult.getClientIdIssuedAt());
+		assertThat(clientRegistrationResult.getClientSecret()).isEqualTo(registeredClientResult.getClientSecret());
+		assertThat(clientRegistrationResult.getClientSecretExpiresAt())
+			.isEqualTo(registeredClientResult.getClientSecretExpiresAt());
+		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) -> grantTypes.add(authorizationGrantType.getValue()));
+		assertThat(clientRegistrationResult.getGrantTypes()).containsExactlyInAnyOrderElementsOf(grantTypes);
+
+		assertThat(clientRegistrationResult.getResponseTypes())
+			.containsExactly(OAuth2AuthorizationResponseType.CODE.getValue());
+		assertThat(clientRegistrationResult.getScopes())
+			.containsExactlyInAnyOrderElementsOf(registeredClientResult.getScopes());
+		assertThat(clientRegistrationResult.getTokenEndpointAuthenticationMethod())
+			.isEqualTo(registeredClientResult.getClientAuthenticationMethods().iterator().next().getValue());
+		assertThat(clientRegistrationResult.getIdTokenSignedResponseAlgorithm())
+			.isEqualTo(registeredClientResult.getTokenSettings().getIdTokenSignatureAlgorithm().getName());
+
+		AuthorizationServerContext authorizationServerContext = AuthorizationServerContextHolder.getContext();
+		String expectedRegistrationClientUrl = UriComponentsBuilder
+			.fromUriString(authorizationServerContext.getIssuer())
+			.path(authorizationServerContext.getAuthorizationServerSettings().getOidcClientRegistrationEndpoint())
+			.queryParam(OAuth2ParameterNames.CLIENT_ID, registeredClientResult.getClientId())
+			.toUriString();
+
+		assertThat(clientRegistrationResult.getRegistrationClientUrl().toString())
+			.isEqualTo(expectedRegistrationClientUrl);
+		assertThat(clientRegistrationResult.getRegistrationAccessToken()).isEqualTo(jwt.getTokenValue());
+	}
+
+	private static Jwt createJwtClientRegistration() {
+		return createJwt(Collections.singleton("client.create"));
+	}
+
+	private static Jwt createJwtClientConfiguration() {
+		return createJwt(Collections.singleton("client.read"));
+	}
+
+	private static Jwt createJwt(Set<String> scopes) {
+		// @formatter:off
+		JwsHeader jwsHeader = TestJwsHeaders.jwsHeader()
+				.build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet()
+				.claim(OAuth2ParameterNames.SCOPE, scopes)
+				.build();
+		Jwt jwt = Jwt.withTokenValue("jwt-access-token")
+				.headers((headers) -> headers.putAll(jwsHeader.getHeaders()))
+				.claims((claims) -> claims.putAll(jwtClaimsSet.getClaims()))
+				.build();
+		// @formatter:on
+		return jwt;
+	}
+
+}

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

@@ -0,0 +1,92 @@
+/*
+ * Copyright 2020-2022 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 org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcClientRegistrationAuthenticationToken}.
+ *
+ * @author Joe Grandja
+ */
+public class OidcClientRegistrationAuthenticationTokenTests {
+
+	private TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+
+	private OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+		.redirectUri("https://client.example.com")
+		.build();
+
+	@Test
+	public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationAuthenticationToken(null, this.clientRegistration))
+			.withMessage("principal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientRegistrationNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(
+					() -> new OidcClientRegistrationAuthenticationToken(this.principal, (OidcClientRegistration) null))
+			.withMessage("clientRegistration cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientIdNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationAuthenticationToken(this.principal, (String) null))
+			.withMessage("clientId cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenClientIdEmptyThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationAuthenticationToken(this.principal, ""))
+			.withMessage("clientId cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenOidcClientRegistrationProvidedThenCreated() {
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				this.principal, this.clientRegistration);
+
+		assertThat(authentication.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getClientRegistration()).isEqualTo(this.clientRegistration);
+		assertThat(authentication.getClientId()).isNull();
+		assertThat(authentication.isAuthenticated()).isEqualTo(this.principal.isAuthenticated());
+	}
+
+	@Test
+	public void constructorWhenClientIdProvidedThenCreated() {
+		OidcClientRegistrationAuthenticationToken authentication = new OidcClientRegistrationAuthenticationToken(
+				this.principal, "client-1");
+
+		assertThat(authentication.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getClientRegistration()).isNull();
+		assertThat(authentication.getClientId()).isEqualTo("client-1");
+		assertThat(authentication.isAuthenticated()).isEqualTo(this.principal.isAuthenticated());
+	}
+
+}

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

@@ -0,0 +1,589 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.oidc.authentication;
+
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Date;
+import java.util.List;
+import java.util.function.Consumer;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+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.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.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * 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.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE));
+	}
+
+	@Test
+	public void authenticateWhenIdTokenInvalidatedThenThrowOAuth2AuthenticationException() {
+		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());
+				metadata.put(OAuth2Authorization.Token.INVALIDATED_METADATA_NAME, true);
+			})
+			.build();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+
+		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("id_token_hint");
+			});
+
+		verify(this.authorizationService).findByToken(eq(authentication.getIdTokenHint()), 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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(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.getIdTokenHint()), 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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(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.getIdTokenHint()), 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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(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_REQUEST);
+				assertThat(error.getDescription()).contains(OAuth2ParameterNames.CLIENT_ID);
+			});
+		verify(this.authorizationService).findByToken(eq(authentication.getIdTokenHint()), 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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(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.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void setAuthenticationValidatorWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.authenticationProvider.setAuthenticationValidator(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("authenticationValidator cannot be null");
+	}
+
+	@Test
+	public void authenticateWhenCustomAuthenticationValidatorThenUsed() throws NoSuchAlgorithmException {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		String sessionId = "session-1";
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.subject(principal.getName())
+			.audience(Collections.singleton(registeredClient.getClientId()))
+			.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+			.expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+			.claim("sid", createHash(sessionId))
+			.build();
+
+		@SuppressWarnings("unchecked")
+		Consumer<OidcLogoutAuthenticationContext> authenticationValidator = mock(Consumer.class);
+		this.authenticationProvider.setAuthenticationValidator(authenticationValidator);
+
+		authenticateValidIdToken(principal, registeredClient, sessionId, idToken);
+		verify(authenticationValidator).accept(any(OidcLogoutAuthenticationContext.class));
+	}
+
+	@Test
+	public void authenticateWhenMissingSubThenThrowOAuth2AuthenticationException() {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(registeredClient);
+
+		principal.setAuthenticated(true);
+
+		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("sub");
+			});
+		verify(this.authorizationService).findByToken(eq(authentication.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(eq(authorization.getRegisteredClientId()));
+	}
+
+	// gh-1235
+	@Test
+	public void authenticateWhenInvalidPrincipalThenThrowOAuth2AuthenticationException() {
+		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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(registeredClient);
+
+		principal.setAuthenticated(true);
+
+		TestingAuthenticationToken otherPrincipal = new TestingAuthenticationToken("other-principal", "credentials");
+		otherPrincipal.setAuthenticated(true);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(idToken.getTokenValue(),
+				otherPrincipal, "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("sub");
+			});
+		verify(this.authorizationService).findByToken(eq(authentication.getIdTokenHint()), 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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(registeredClient);
+
+		String sessionId = "session-1";
+		List<SessionInformation> sessions = Collections
+			.singletonList(new SessionInformation(principal.getPrincipal(), sessionId, Date.from(Instant.now())));
+		given(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true))).willReturn(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.getIdTokenHint()), 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();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(registeredClient);
+
+		String sessionId = "session-1";
+		List<SessionInformation> sessions = Collections
+			.singletonList(new SessionInformation(principal.getPrincipal(), sessionId, Date.from(Instant.now())));
+		given(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true))).willReturn(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.getIdTokenHint()), eq(ID_TOKEN_TOKEN_TYPE));
+		verify(this.registeredClientRepository).findById(eq(authorization.getRegisteredClientId()));
+	}
+
+	@Test
+	public void authenticateWhenValidIdTokenThenAuthenticated() throws Exception {
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		String sessionId = "session-1";
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.subject(principal.getName())
+			.audience(Collections.singleton(registeredClient.getClientId()))
+			.issuedAt(Instant.now().minusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+			.expiresAt(Instant.now().plusSeconds(60).truncatedTo(ChronoUnit.MILLIS))
+			.claim("sid", createHash(sessionId))
+			.build();
+		authenticateValidIdToken(principal, registeredClient, sessionId, idToken);
+	}
+
+	// gh-1440
+	@Test
+	public void authenticateWhenValidExpiredIdTokenThenAuthenticated() throws Exception {
+		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().minusSeconds(30).truncatedTo(ChronoUnit.MILLIS)) // Expired
+			.claim("sid", createHash(sessionId))
+			.build();
+		authenticateValidIdToken(principal, registeredClient, sessionId, idToken);
+	}
+
+	private void authenticateValidIdToken(Authentication principal, RegisteredClient registeredClient, String sessionId,
+			OidcIdToken idToken) {
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.principalName(principal.getName())
+			.token(idToken,
+					(metadata) -> metadata.put(OAuth2Authorization.Token.CLAIMS_METADATA_NAME, idToken.getClaims()))
+			.build();
+		given(this.authorizationService.findByToken(eq(idToken.getTokenValue()), eq(ID_TOKEN_TOKEN_TYPE)))
+			.willReturn(authorization);
+		given(this.registeredClientRepository.findById(eq(authorization.getRegisteredClientId())))
+			.willReturn(registeredClient);
+
+		SessionInformation sessionInformation = new SessionInformation(principal.getPrincipal(), sessionId,
+				Date.from(Instant.now()));
+		List<SessionInformation> sessions = Collections.singletonList(sessionInformation);
+		given(this.sessionRegistry.getAllSessions(eq(principal.getPrincipal()), eq(true))).willReturn(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.getIdTokenHint()), 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);
+		assertThat(authenticationResult.getSessionId()).isEqualTo(sessionInformation.getSessionId());
+		assertThat(authenticationResult.getClientId()).isEqualTo(registeredClient.getClientId());
+		assertThat(authenticationResult.getPostLogoutRedirectUri()).isEqualTo(postLogoutRedirectUri);
+		assertThat(authenticationResult.getState()).isEqualTo(state);
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+	}
+
+	private static String createHash(String value) throws NoSuchAlgorithmException {
+		MessageDigest md = MessageDigest.getInstance("SHA-256");
+		byte[] digest = md.digest(value.getBytes(StandardCharsets.US_ASCII));
+		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+	}
+
+}

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

@@ -0,0 +1,116 @@
+/*
+ * 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 org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+
+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 idTokenHint = "id-token";
+
+	private final OidcIdToken idToken = OidcIdToken.withTokenValue(this.idTokenHint)
+		.issuer("https://provider.com")
+		.subject("principal")
+		.issuedAt(Instant.now().minusSeconds(60))
+		.expiresAt(Instant.now().plusSeconds(60))
+		.build();
+
+	private final TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "credentials");
+
+	private final String sessionId = "session-1";
+
+	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 constructorWhenIdTokenHintEmptyThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcLogoutAuthenticationToken("", this.principal, this.sessionId, this.clientId,
+					this.postLogoutRedirectUri, this.state))
+			.withMessage("idTokenHint cannot be empty");
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcLogoutAuthenticationToken((String) null, this.principal, this.sessionId,
+					this.clientId, this.postLogoutRedirectUri, this.state))
+			.withMessage("idTokenHint cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenIdTokenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcLogoutAuthenticationToken((OidcIdToken) null, this.principal, this.sessionId,
+					this.clientId, this.postLogoutRedirectUri, this.state))
+			.withMessage("idToken cannot be null");
+	}
+
+	@Test
+	public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcLogoutAuthenticationToken(this.idTokenHint, null, this.sessionId, this.clientId,
+					this.postLogoutRedirectUri, this.state))
+			.withMessage("principal cannot be null");
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcLogoutAuthenticationToken(this.idToken, null, this.sessionId, this.clientId,
+					this.postLogoutRedirectUri, this.state))
+			.withMessage("principal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenIdTokenHintProvidedThenCreated() {
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken(this.idTokenHint,
+				this.principal, this.sessionId, this.clientId, this.postLogoutRedirectUri, this.state);
+		assertThat(authentication.getPrincipal()).isEqualTo(this.principal);
+		assertThat(authentication.getCredentials().toString()).isEmpty();
+		assertThat(authentication.getIdTokenHint()).isEqualTo(this.idTokenHint);
+		assertThat(authentication.getIdToken()).isNull();
+		assertThat(authentication.getSessionId()).isEqualTo(this.sessionId);
+		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 constructorWhenIdTokenProvidedThenCreated() {
+		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.getIdTokenHint()).isEqualTo(this.idToken.getTokenValue());
+		assertThat(authentication.getIdToken()).isEqualTo(this.idToken);
+		assertThat(authentication.getSessionId()).isEqualTo(this.sessionId);
+		assertThat(authentication.getClientId()).isEqualTo(this.clientId);
+		assertThat(authentication.getPostLogoutRedirectUri()).isEqualTo(this.postLogoutRedirectUri);
+		assertThat(authentication.getState()).isEqualTo(this.state);
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+}

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

@@ -0,0 +1,286 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.oidc.authentication;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.oidc.OidcIdToken;
+import org.springframework.security.oauth2.core.oidc.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.Jwt;
+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.resource.authentication.JwtAuthenticationToken;
+
+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.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OidcUserInfoAuthenticationProvider}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OidcUserInfoAuthenticationProviderTests {
+
+	private OAuth2AuthorizationService authorizationService;
+
+	private OidcUserInfoAuthenticationProvider authenticationProvider;
+
+	@BeforeEach
+	public void setUp() {
+		this.authorizationService = mock(OAuth2AuthorizationService.class);
+		this.authenticationProvider = new OidcUserInfoAuthenticationProvider(this.authorizationService);
+	}
+
+	@Test
+	public void constructorWhenAuthorizationServiceNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new OidcUserInfoAuthenticationProvider(null))
+			.withMessage("authorizationService cannot be null");
+	}
+
+	@Test
+	public void setUserInfoMapperWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.authenticationProvider.setUserInfoMapper(null))
+			.withMessage("userInfoMapper cannot be null");
+	}
+
+	@Test
+	public void supportsWhenTypeOidcUserInfoAuthenticationTokenThenReturnTrue() {
+		assertThat(this.authenticationProvider.supports(OidcUserInfoAuthenticationToken.class)).isTrue();
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotOfExpectedTypeThenThrowOAuth2AuthenticationException() {
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(
+				new UsernamePasswordAuthenticationToken(null, null));
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+
+		verifyNoInteractions(this.authorizationService);
+	}
+
+	@Test
+	public void authenticateWhenPrincipalNotAuthenticatedThenThrowOAuth2AuthenticationException() {
+		String tokenValue = "token";
+		JwtAuthenticationToken principal = createJwtAuthenticationToken(tokenValue);
+		principal.setAuthenticated(false);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+
+		verifyNoInteractions(this.authorizationService);
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotFoundThenThrowOAuth2AuthenticationException() {
+		String tokenValue = "token";
+		JwtAuthenticationToken principal = createJwtAuthenticationToken(tokenValue);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+
+		verify(this.authorizationService).findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotActiveThenThrowOAuth2AuthenticationException() {
+		String tokenValue = "token";
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization().build();
+		authorization = OAuth2Authorization.from(authorization)
+			.invalidate(authorization.getAccessToken().getToken())
+			.build();
+		given(this.authorizationService.findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = createJwtAuthenticationToken(tokenValue);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+
+		verify(this.authorizationService).findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenAccessTokenNotAuthorizedThenThrowOAuth2AuthenticationException() {
+		String tokenValue = "token";
+		given(this.authorizationService.findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(TestOAuth2Authorizations.authorization().build());
+
+		JwtAuthenticationToken principal = createJwtAuthenticationToken(tokenValue);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INSUFFICIENT_SCOPE);
+
+		verify(this.authorizationService).findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenIdTokenNullThenThrowOAuth2AuthenticationException() {
+		String tokenValue = "token";
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization()
+			.token(createAuthorization(tokenValue).getAccessToken().getToken())
+			.build();
+		given(this.authorizationService.findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(authorization);
+
+		JwtAuthenticationToken principal = createJwtAuthenticationToken(tokenValue);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+
+		assertThatThrownBy(() -> this.authenticationProvider.authenticate(authentication))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.INVALID_TOKEN);
+
+		verify(this.authorizationService).findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	@Test
+	public void authenticateWhenValidAccessTokenThenReturnUserInfo() {
+		String tokenValue = "access-token";
+		given(this.authorizationService.findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN)))
+			.willReturn(createAuthorization(tokenValue));
+
+		JwtAuthenticationToken principal = createJwtAuthenticationToken(tokenValue);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+		OidcUserInfoAuthenticationToken authenticationResult = (OidcUserInfoAuthenticationToken) this.authenticationProvider
+			.authenticate(authentication);
+
+		assertThat(authenticationResult.getPrincipal()).isEqualTo(principal);
+		assertThat(authenticationResult.getCredentials()).isEqualTo("");
+		assertThat(authenticationResult.isAuthenticated()).isTrue();
+
+		OidcUserInfo userInfo = authenticationResult.getUserInfo();
+		assertThat(userInfo.getClaims()).hasSize(20);
+		assertThat(userInfo.getSubject()).isEqualTo("user1");
+		assertThat(userInfo.getFullName()).isEqualTo("First Last");
+		assertThat(userInfo.getGivenName()).isEqualTo("First");
+		assertThat(userInfo.getFamilyName()).isEqualTo("Last");
+		assertThat(userInfo.getMiddleName()).isEqualTo("Middle");
+		assertThat(userInfo.getNickName()).isEqualTo("User");
+		assertThat(userInfo.getPreferredUsername()).isEqualTo("user");
+		assertThat(userInfo.getProfile()).isEqualTo("https://example.com/user1");
+		assertThat(userInfo.getPicture()).isEqualTo("https://example.com/user1.jpg");
+		assertThat(userInfo.getWebsite()).isEqualTo("https://example.com");
+		assertThat(userInfo.getEmail()).isEqualTo("user1@example.com");
+		assertThat(userInfo.getEmailVerified()).isEqualTo(true);
+		assertThat(userInfo.getGender()).isEqualTo("female");
+		assertThat(userInfo.getBirthdate()).isEqualTo("1970-01-01");
+		assertThat(userInfo.getZoneInfo()).isEqualTo("Europe/Paris");
+		assertThat(userInfo.getLocale()).isEqualTo("en-US");
+		assertThat(userInfo.getPhoneNumber()).isEqualTo("+1 (604) 555-1234;ext=5678");
+		assertThat(userInfo.getPhoneNumberVerified()).isEqualTo(false);
+		assertThat(userInfo.getAddress().getFormatted())
+			.isEqualTo("Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance");
+		assertThat(userInfo.getUpdatedAt()).isEqualTo(Instant.parse("1970-01-01T00:00:00Z"));
+
+		verify(this.authorizationService).findByToken(eq(tokenValue), eq(OAuth2TokenType.ACCESS_TOKEN));
+	}
+
+	private static OAuth2Authorization createAuthorization(String tokenValue) {
+		Instant now = Instant.now();
+		Set<String> scopes = new HashSet<>(Arrays.asList(OidcScopes.OPENID, OidcScopes.ADDRESS, OidcScopes.EMAIL,
+				OidcScopes.PHONE, OidcScopes.PROFILE));
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, tokenValue, now,
+				now.plusSeconds(300), scopes);
+		OidcIdToken idToken = new OidcIdToken("id-token", now, now.plusSeconds(900), createUserInfo().getClaims());
+
+		return TestOAuth2Authorizations.authorization().token(accessToken).token(idToken).build();
+	}
+
+	private static JwtAuthenticationToken createJwtAuthenticationToken(String tokenValue) {
+		Instant now = Instant.now();
+		// @formatter:off
+		Jwt jwt = Jwt.withTokenValue(tokenValue)
+				.header(JoseHeaderNames.ALG, SignatureAlgorithm.RS256.getName())
+				.issuedAt(now)
+				.expiresAt(now.plusSeconds(300))
+				.claim(StandardClaimNames.SUB, "user")
+				.build();
+		// @formatter:on
+		return new JwtAuthenticationToken(jwt, Collections.emptyList());
+	}
+
+	private static OidcUserInfo createUserInfo() {
+		// @formatter:off
+		return OidcUserInfo.builder()
+				.subject("user1")
+				.name("First Last")
+				.givenName("First")
+				.familyName("Last")
+				.middleName("Middle")
+				.nickname("User")
+				.preferredUsername("user")
+				.profile("https://example.com/user1")
+				.picture("https://example.com/user1.jpg")
+				.website("https://example.com")
+				.email("user1@example.com")
+				.emailVerified(true)
+				.gender("female")
+				.birthdate("1970-01-01")
+				.zoneinfo("Europe/Paris")
+				.locale("en-US")
+				.phoneNumber("+1 (604) 555-1234;ext=5678")
+				.phoneNumberVerified(false)
+				.claim("address", Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"))
+				.updatedAt("1970-01-01T00:00:00Z")
+				.build();
+		// @formatter:on
+	}
+
+}

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

@@ -0,0 +1,61 @@
+/*
+ * Copyright 2020-2022 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.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcUserInfoAuthenticationToken}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OidcUserInfoAuthenticationTokenTests {
+
+	@Test
+	public void constructorWhenPrincipalNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new OidcUserInfoAuthenticationToken(null))
+			.withMessage("principal cannot be null");
+	}
+
+	@Test
+	public void constructorWhenPrincipalProvidedThenCreated() {
+		UsernamePasswordAuthenticationToken principal = new UsernamePasswordAuthenticationToken(null, null);
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+		assertThat(authentication.getPrincipal()).isEqualTo(principal);
+		assertThat(authentication.getUserInfo()).isNull();
+		assertThat(authentication.isAuthenticated()).isFalse();
+	}
+
+	@Test
+	public void constructorWhenPrincipalAndUserInfoProvidedThenCreated() {
+		UsernamePasswordAuthenticationToken principal = new UsernamePasswordAuthenticationToken(null, null);
+		OidcUserInfo userInfo = new OidcUserInfo(Collections.singletonMap(StandardClaimNames.SUB, "user"));
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal, userInfo);
+		assertThat(authentication.getPrincipal()).isEqualTo(principal);
+		assertThat(authentication.getUserInfo()).isEqualTo(userInfo);
+		assertThat(authentication.isAuthenticated()).isTrue();
+	}
+
+}

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

@@ -0,0 +1,278 @@
+/*
+ * 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.http.converter;
+
+import java.net.URL;
+import java.time.Instant;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OidcClientRegistrationHttpMessageConverter}
+ *
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ * @since 0.1.1
+ */
+public class OidcClientRegistrationHttpMessageConverterTests {
+
+	private final OidcClientRegistrationHttpMessageConverter messageConverter = new OidcClientRegistrationHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOidcClientRegistrationThenTrue() {
+		assertThat(this.messageConverter.supports(OidcClientRegistration.class)).isTrue();
+	}
+
+	@Test
+	public void setClientRegistrationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setClientRegistrationConverter(null))
+			.withMessageContaining("clientRegistrationConverter cannot be null");
+	}
+
+	@Test
+	public void setClientRegistrationParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setClientRegistrationParametersConverter(null))
+			.withMessageContaining("clientRegistrationParametersConverter cannot be null");
+	}
+
+	@Test
+	public void readInternalWhenRequiredParametersThenSuccess() {
+		// @formatter:off
+		String clientRegistrationRequest = "{\n"
+				+ "		\"redirect_uris\": [\n"
+				+ "			\"https://client.example.com\"\n"
+				+ "		]\n"
+				+ "}\n";
+		// @formatter:on
+
+		MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationRequest.getBytes(),
+				HttpStatus.OK);
+		OidcClientRegistration clientRegistration = this.messageConverter.readInternal(OidcClientRegistration.class,
+				response);
+
+		assertThat(clientRegistration.getClaims()).hasSize(1);
+		assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com");
+	}
+
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String clientRegistrationRequest = "{\n"
+				+ "		\"client_id\": \"client-id\",\n"
+				+ "		\"client_id_issued_at\": 1607633867,\n"
+				+ "		\"client_secret\": \"client-secret\",\n"
+				+ "		\"client_secret_expires_at\": 1607637467,\n"
+				+ "		\"client_name\": \"client-name\",\n"
+				+ "		\"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"
+				+ "			\"authorization_code\",\n"
+				+ "			\"client_credentials\"\n"
+				+ "		],\n"
+				+ "		\"response_types\":[\n"
+				+ "			\"code\"\n"
+				+ "		],\n"
+				+ "		\"scope\": \"scope1 scope2\",\n"
+				+ "		\"jwks_uri\": \"https://client.example.com/jwks\",\n"
+				+ "		\"id_token_signed_response_alg\": \"RS256\",\n"
+				+ "		\"a-claim\": \"a-value\"\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationRequest.getBytes(),
+				HttpStatus.OK);
+		OidcClientRegistration clientRegistration = this.messageConverter.readInternal(OidcClientRegistration.class,
+				response);
+
+		assertThat(clientRegistration.getClientId()).isEqualTo("client-id");
+		assertThat(clientRegistration.getClientIdIssuedAt()).isEqualTo(Instant.ofEpochSecond(1607633867L));
+		assertThat(clientRegistration.getClientSecret()).isEqualTo("client-secret");
+		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");
+		assertThat(clientRegistration.getResponseTypes()).containsOnly("code");
+		assertThat(clientRegistration.getScopes()).containsExactlyInAnyOrder("scope1", "scope2");
+		assertThat(clientRegistration.getJwkSetUrl()).isEqualTo(new URL("https://client.example.com/jwks"));
+		assertThat(clientRegistration.getIdTokenSignedResponseAlgorithm()).isEqualTo("RS256");
+		assertThat(clientRegistration.getClaimAsString("a-claim")).isEqualTo("a-value");
+	}
+
+	@Test
+	public void readInternalWhenClientSecretNoExpiryThenSuccess() {
+		// @formatter:off
+		String clientRegistrationRequest = "{\n"
+				+ "		\"client_id\": \"client-id\",\n"
+				+ "		\"client_secret\": \"client-secret\",\n"
+				+ "		\"client_secret_expires_at\": 0,\n"
+				+ "		\"redirect_uris\": [\n"
+				+ "			\"https://client.example.com\"\n"
+				+ "		]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(clientRegistrationRequest.getBytes(),
+				HttpStatus.OK);
+		OidcClientRegistration clientRegistration = this.messageConverter.readInternal(OidcClientRegistration.class,
+				response);
+
+		assertThat(clientRegistration.getClaims()).hasSize(3);
+		assertThat(clientRegistration.getClientId()).isEqualTo("client-id");
+		assertThat(clientRegistration.getClientSecret()).isEqualTo("client-secret");
+		assertThat(clientRegistration.getClientSecretExpiresAt()).isNull();
+		assertThat(clientRegistration.getRedirectUris()).containsOnly("https://client.example.com");
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setClientRegistrationConverter((source) -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OidcClientRegistration.class, response))
+			.withMessageContaining("An error occurred reading the OpenID Client Registration")
+			.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void writeInternalWhenClientRegistrationThenSuccess() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientId("client-id")
+				.clientIdIssuedAt(Instant.ofEpochSecond(1607633867))
+				.clientSecret("client-secret")
+				.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())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.jwkSetUrl("https://client.example.com/jwks")
+				.idTokenSignedResponseAlgorithm(SignatureAlgorithm.RS256.getName())
+				.registrationAccessToken("registration-access-token")
+				.registrationClientUrl("https://auth-server.com/connect/register?client_id=1")
+				.claim("a-claim", "a-value")
+				.build();
+		// @formatter:on
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+		this.messageConverter.writeInternal(clientRegistration, outputMessage);
+
+		String clientRegistrationResponse = outputMessage.getBodyAsString();
+		assertThat(clientRegistrationResponse).contains("\"client_id\":\"client-id\"");
+		assertThat(clientRegistrationResponse).contains("\"client_id_issued_at\":1607633867");
+		assertThat(clientRegistrationResponse).contains("\"client_secret\":\"client-secret\"");
+		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\"]");
+		assertThat(clientRegistrationResponse).contains("\"response_types\":[\"code\"]");
+		assertThat(clientRegistrationResponse).contains("\"scope\":\"scope1 scope2\"");
+		assertThat(clientRegistrationResponse).contains("\"jwks_uri\":\"https://client.example.com/jwks\"");
+		assertThat(clientRegistrationResponse).contains("\"id_token_signed_response_alg\":\"RS256\"");
+		assertThat(clientRegistrationResponse).contains("\"registration_access_token\":\"registration-access-token\"");
+		assertThat(clientRegistrationResponse)
+			.contains("\"registration_client_uri\":\"https://auth-server.com/connect/register?client_id=1\"");
+		assertThat(clientRegistrationResponse).contains("\"a-claim\":\"a-value\"");
+	}
+
+	@Test
+	public void writeInternalWhenClientSecretNoExpiryThenSuccess() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientId("client-id")
+				.clientSecret("client-secret")
+				.redirectUri("https://client.example.com")
+				.build();
+		// @formatter:on
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+		this.messageConverter.writeInternal(clientRegistration, outputMessage);
+
+		String clientRegistrationResponse = outputMessage.getBodyAsString();
+		assertThat(clientRegistrationResponse).contains("\"client_id\":\"client-id\"");
+		assertThat(clientRegistrationResponse).contains("\"client_secret\":\"client-secret\"");
+		assertThat(clientRegistrationResponse).contains("\"client_secret_expires_at\":0");
+		assertThat(clientRegistrationResponse).contains("\"redirect_uris\":[\"https://client.example.com\"]");
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OidcClientRegistration, Map<String, Object>> failingConverter = (source) -> {
+			throw new RuntimeException(errorMessage);
+		};
+		this.messageConverter.setClientRegistrationParametersConverter(failingConverter);
+
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.redirectUri("https://client.example.com")
+				.build();
+		// @formatter:off
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatThrownBy(() -> this.messageConverter.writeInternal(clientRegistration, outputMessage))
+				.isInstanceOf(HttpMessageNotWritableException.class)
+				.hasMessageContaining("An error occurred writing the OpenID Client Registration")
+				.hasMessageContaining(errorMessage);
+	}
+}

+ 230 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcProviderConfigurationHttpMessageConverterTests.java

@@ -0,0 +1,230 @@
+/*
+ * 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.http.converter;
+
+import java.net.URL;
+import java.util.Arrays;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcProviderConfiguration;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcProviderConfigurationHttpMessageConverter}
+ *
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcProviderConfigurationHttpMessageConverterTests {
+
+	private final OidcProviderConfigurationHttpMessageConverter messageConverter = new OidcProviderConfigurationHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOidcProviderConfigurationThenTrue() {
+		assertThat(this.messageConverter.supports(OidcProviderConfiguration.class)).isTrue();
+	}
+
+	@Test
+	public void setProviderConfigurationParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setProviderConfigurationParametersConverter(null));
+	}
+
+	@Test
+	public void setProviderConfigurationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setProviderConfigurationConverter(null));
+	}
+
+	@Test
+	public void readInternalWhenRequiredParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String providerConfigurationResponse = "{\n"
+				+ "		\"issuer\": \"https://example.com\",\n"
+				+ "		\"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n"
+				+ "		\"token_endpoint\": \"https://example.com/oauth2/token\",\n"
+				+ "		\"jwks_uri\": \"https://example.com/oauth2/jwks\",\n"
+				+ "		\"response_types_supported\": [\"code\"],\n"
+				+ "		\"subject_types_supported\": [\"public\"],\n"
+				+ "		\"id_token_signing_alg_values_supported\": [\"RS256\"]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(),
+				HttpStatus.OK);
+		OidcProviderConfiguration providerConfiguration = this.messageConverter
+			.readInternal(OidcProviderConfiguration.class, response);
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/oauth2/token"));
+		assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(new URL("https://example.com/oauth2/jwks"));
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256");
+		assertThat(providerConfiguration.getScopes()).isNull();
+		assertThat(providerConfiguration.getGrantTypes()).isNull();
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods()).isNull();
+	}
+
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() throws Exception {
+		// @formatter:off
+		String providerConfigurationResponse = "{\n"
+				+ "		\"issuer\": \"https://example.com\",\n"
+				+ "		\"authorization_endpoint\": \"https://example.com/oauth2/authorize\",\n"
+				+ "		\"token_endpoint\": \"https://example.com/oauth2/token\",\n"
+				+ "		\"jwks_uri\": \"https://example.com/oauth2/jwks\",\n"
+				+ "		\"userinfo_endpoint\": \"https://example.com/userinfo\",\n"
+				+ "		\"scopes_supported\": [\"openid\"],\n"
+				+ "		\"response_types_supported\": [\"code\"],\n"
+				+ "		\"grant_types_supported\": [\"authorization_code\", \"client_credentials\"],\n"
+				+ "		\"subject_types_supported\": [\"public\"],\n"
+				+ "		\"id_token_signing_alg_values_supported\": [\"RS256\"],\n"
+				+ "		\"token_endpoint_auth_methods_supported\": [\"client_secret_basic\"],\n"
+				+ "		\"custom_claim\": \"value\",\n"
+				+ "		\"custom_collection_claim\": [\"value1\", \"value2\"]\n"
+				+ "}\n";
+		// @formatter:on
+		MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(),
+				HttpStatus.OK);
+		OidcProviderConfiguration providerConfiguration = this.messageConverter
+			.readInternal(OidcProviderConfiguration.class, response);
+
+		assertThat(providerConfiguration.getIssuer()).isEqualTo(new URL("https://example.com"));
+		assertThat(providerConfiguration.getAuthorizationEndpoint())
+			.isEqualTo(new URL("https://example.com/oauth2/authorize"));
+		assertThat(providerConfiguration.getTokenEndpoint()).isEqualTo(new URL("https://example.com/oauth2/token"));
+		assertThat(providerConfiguration.getJwkSetUrl()).isEqualTo(new URL("https://example.com/oauth2/jwks"));
+		assertThat(providerConfiguration.getUserInfoEndpoint()).isEqualTo(new URL("https://example.com/userinfo"));
+		assertThat(providerConfiguration.getScopes()).containsExactly("openid");
+		assertThat(providerConfiguration.getResponseTypes()).containsExactly("code");
+		assertThat(providerConfiguration.getGrantTypes()).containsExactlyInAnyOrder("authorization_code",
+				"client_credentials");
+		assertThat(providerConfiguration.getSubjectTypes()).containsExactly("public");
+		assertThat(providerConfiguration.getIdTokenSigningAlgorithms()).containsExactly("RS256");
+		assertThat(providerConfiguration.getTokenEndpointAuthenticationMethods())
+			.containsExactly(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue());
+		assertThat(providerConfiguration.<String>getClaim("custom_claim")).isEqualTo("value");
+		assertThat(providerConfiguration.getClaimAsStringList("custom_collection_claim"))
+			.containsExactlyInAnyOrder("value1", "value2");
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setProviderConfigurationConverter((source) -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OidcProviderConfiguration.class, response))
+			.withMessageContaining("An error occurred reading the OpenID Provider Configuration")
+			.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void readInternalWhenInvalidProviderConfigurationThenThrowException() {
+		String providerConfigurationResponse = "{ \"issuer\": null }";
+		MockClientHttpResponse response = new MockClientHttpResponse(providerConfigurationResponse.getBytes(),
+				HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OidcProviderConfiguration.class, response))
+			.withMessageContaining("An error occurred reading the OpenID Provider Configuration")
+			.withMessageContaining("issuer cannot be null");
+	}
+
+	@Test
+	public void writeInternalWhenProviderConfigurationThenSuccess() {
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.jwkSetUrl("https://example.com/oauth2/jwks")
+			.userInfoEndpoint("https://example.com/userinfo")
+			.scope("openid")
+			.responseType("code")
+			.grantType("authorization_code")
+			.grantType("client_credentials")
+			.subjectType("public")
+			.idTokenSigningAlgorithm("RS256")
+			.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+			.claim("custom_claim", "value")
+			.claim("custom_collection_claim", Arrays.asList("value1", "value2"))
+			.build();
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(providerConfiguration, outputMessage);
+
+		String providerConfigurationResponse = outputMessage.getBodyAsString();
+		assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com\"");
+		assertThat(providerConfigurationResponse)
+			.contains("\"authorization_endpoint\":\"https://example.com/oauth2/authorize\"");
+		assertThat(providerConfigurationResponse).contains("\"token_endpoint\":\"https://example.com/oauth2/token\"");
+		assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/jwks\"");
+		assertThat(providerConfigurationResponse).contains("\"userinfo_endpoint\":\"https://example.com/userinfo\"");
+		assertThat(providerConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]");
+		assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]");
+		assertThat(providerConfigurationResponse)
+			.contains("\"grant_types_supported\":[\"authorization_code\",\"client_credentials\"]");
+		assertThat(providerConfigurationResponse).contains("\"subject_types_supported\":[\"public\"]");
+		assertThat(providerConfigurationResponse).contains("\"id_token_signing_alg_values_supported\":[\"RS256\"]");
+		assertThat(providerConfigurationResponse)
+			.contains("\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\"]");
+		assertThat(providerConfigurationResponse).contains("\"custom_claim\":\"value\"");
+		assertThat(providerConfigurationResponse).contains("\"custom_collection_claim\":[\"value1\",\"value2\"]");
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowsException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OidcProviderConfiguration, Map<String, Object>> failingConverter = (source) -> {
+			throw new RuntimeException(errorMessage);
+		};
+		this.messageConverter.setProviderConfigurationParametersConverter(failingConverter);
+
+		OidcProviderConfiguration providerConfiguration = OidcProviderConfiguration.builder()
+			.issuer("https://example.com")
+			.authorizationEndpoint("https://example.com/oauth2/authorize")
+			.tokenEndpoint("https://example.com/oauth2/token")
+			.jwkSetUrl("https://example.com/oauth2/jwks")
+			.responseType("code")
+			.subjectType("public")
+			.idTokenSigningAlgorithm("RS256")
+			.build();
+
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatExceptionOfType(HttpMessageNotWritableException.class)
+			.isThrownBy(() -> this.messageConverter.writeInternal(providerConfiguration, outputMessage))
+			.withMessageContaining("An error occurred writing the OpenID Provider Configuration")
+			.withMessageContaining(errorMessage);
+	}
+
+}

+ 230 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/http/converter/OidcUserInfoHttpMessageConverterTests.java

@@ -0,0 +1,230 @@
+/*
+ * Copyright 2020-2022 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.http.converter;
+
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.core.convert.converter.Converter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.http.converter.HttpMessageNotWritableException;
+import org.springframework.mock.http.MockHttpOutputMessage;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link OidcUserInfoHttpMessageConverter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OidcUserInfoHttpMessageConverterTests {
+
+	private final OidcUserInfoHttpMessageConverter messageConverter = new OidcUserInfoHttpMessageConverter();
+
+	@Test
+	public void supportsWhenOidcUserInfoThenTrue() {
+		assertThat(this.messageConverter.supports(OidcUserInfo.class)).isTrue();
+	}
+
+	@Test
+	public void setUserInfoConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.messageConverter.setUserInfoConverter(null));
+	}
+
+	@Test
+	public void setUserInfoParametersConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> this.messageConverter.setUserInfoParametersConverter(null));
+	}
+
+	@Test
+	public void readInternalWhenValidParametersThenSuccess() {
+		// @formatter:off
+		String userInfoResponse = "{\n" +
+				"	\"sub\": \"user1\",\n" +
+				"	\"name\": \"First Last\",\n" +
+				"	\"given_name\": \"First\",\n" +
+				"	\"family_name\": \"Last\",\n" +
+				"	\"middle_name\": \"Middle\",\n" +
+				"	\"nickname\": \"User\",\n" +
+				"	\"preferred_username\": \"user\",\n" +
+				"	\"profile\": \"https://example.com/user1\",\n" +
+				"	\"picture\": \"https://example.com/user1.jpg\",\n" +
+				"	\"website\": \"https://example.com\",\n" +
+				"	\"email\": \"user1@example.com\",\n" +
+				"	\"email_verified\": \"true\",\n" +
+				"	\"gender\": \"female\",\n" +
+				"	\"birthdate\": \"1970-01-01\",\n" +
+				"	\"zoneinfo\": \"Europe/Paris\",\n" +
+				"	\"locale\": \"en-US\",\n" +
+				"	\"phone_number\": \"+1 (604) 555-1234;ext=5678\",\n" +
+				"	\"phone_number_verified\": \"false\",\n" +
+				"	\"address\": {\n" +
+				"		\"formatted\": \"Champ de Mars\\n5 Av. Anatole France\\n75007 Paris\\nFrance\",\n" +
+				"		\"street_address\": \"Champ de Mars\\n5 Av. Anatole France\",\n" +
+				"		\"locality\": \"Paris\",\n" +
+				"		\"postal_code\": \"75007\",\n" +
+				"		\"country\": \"France\"\n" +
+				"	},\n" +
+				"	\"updated_at\": 1607633867\n" +
+				"}\n";
+		// @formatter:on
+
+		MockClientHttpResponse response = new MockClientHttpResponse(userInfoResponse.getBytes(), HttpStatus.OK);
+		OidcUserInfo oidcUserInfo = this.messageConverter.readInternal(OidcUserInfo.class, response);
+
+		assertThat(oidcUserInfo.getSubject()).isEqualTo("user1");
+		assertThat(oidcUserInfo.getFullName()).isEqualTo("First Last");
+		assertThat(oidcUserInfo.getGivenName()).isEqualTo("First");
+		assertThat(oidcUserInfo.getFamilyName()).isEqualTo("Last");
+		assertThat(oidcUserInfo.getMiddleName()).isEqualTo("Middle");
+		assertThat(oidcUserInfo.getNickName()).isEqualTo("User");
+		assertThat(oidcUserInfo.getPreferredUsername()).isEqualTo("user");
+		assertThat(oidcUserInfo.getProfile()).isEqualTo("https://example.com/user1");
+		assertThat(oidcUserInfo.getPicture()).isEqualTo("https://example.com/user1.jpg");
+		assertThat(oidcUserInfo.getWebsite()).isEqualTo("https://example.com");
+		assertThat(oidcUserInfo.getEmail()).isEqualTo("user1@example.com");
+		assertThat(oidcUserInfo.getEmailVerified()).isTrue();
+		assertThat(oidcUserInfo.getGender()).isEqualTo("female");
+		assertThat(oidcUserInfo.getBirthdate()).isEqualTo("1970-01-01");
+		assertThat(oidcUserInfo.getZoneInfo()).isEqualTo("Europe/Paris");
+		assertThat(oidcUserInfo.getLocale()).isEqualTo("en-US");
+		assertThat(oidcUserInfo.getPhoneNumber()).isEqualTo("+1 (604) 555-1234;ext=5678");
+		assertThat(oidcUserInfo.getPhoneNumberVerified()).isFalse();
+		assertThat(oidcUserInfo.getAddress().getFormatted())
+			.isEqualTo("Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance");
+		assertThat(oidcUserInfo.getAddress().getStreetAddress()).isEqualTo("Champ de Mars\n5 Av. Anatole France");
+		assertThat(oidcUserInfo.getAddress().getLocality()).isEqualTo("Paris");
+		assertThat(oidcUserInfo.getAddress().getPostalCode()).isEqualTo("75007");
+		assertThat(oidcUserInfo.getAddress().getCountry()).isEqualTo("France");
+		assertThat(oidcUserInfo.getUpdatedAt()).isEqualTo(Instant.ofEpochSecond(1607633867));
+	}
+
+	@Test
+	public void readInternalWhenFailingConverterThenThrowException() {
+		String errorMessage = "this is not a valid converter";
+		this.messageConverter.setUserInfoConverter((source) -> {
+			throw new RuntimeException(errorMessage);
+		});
+		MockClientHttpResponse response = new MockClientHttpResponse("{}".getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OidcUserInfo.class, response))
+			.withMessageContaining("An error occurred reading the UserInfo response")
+			.withMessageContaining(errorMessage);
+	}
+
+	@Test
+	public void readInternalWhenInvalidResponseThenThrowException() {
+		String userInfoResponse = "{}";
+		MockClientHttpResponse response = new MockClientHttpResponse(userInfoResponse.getBytes(), HttpStatus.OK);
+
+		assertThatExceptionOfType(HttpMessageNotReadableException.class)
+			.isThrownBy(() -> this.messageConverter.readInternal(OidcUserInfo.class, response))
+			.withMessageContaining("An error occurred reading the UserInfo response")
+			.withMessageContaining("claims cannot be empty");
+	}
+
+	@Test
+	public void writeInternalWhenOidcUserInfoThenSuccess() {
+		OidcUserInfo userInfo = createUserInfo();
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		this.messageConverter.writeInternal(userInfo, outputMessage);
+
+		String userInfoResponse = outputMessage.getBodyAsString();
+		assertThat(userInfoResponse).contains("\"sub\":\"user1\"");
+		assertThat(userInfoResponse).contains("\"name\":\"First Last\"");
+		assertThat(userInfoResponse).contains("\"given_name\":\"First\"");
+		assertThat(userInfoResponse).contains("\"family_name\":\"Last\"");
+		assertThat(userInfoResponse).contains("\"middle_name\":\"Middle\"");
+		assertThat(userInfoResponse).contains("\"nickname\":\"User\"");
+		assertThat(userInfoResponse).contains("\"preferred_username\":\"user\"");
+		assertThat(userInfoResponse).contains("\"profile\":\"https://example.com/user1\"");
+		assertThat(userInfoResponse).contains("\"picture\":\"https://example.com/user1.jpg\"");
+		assertThat(userInfoResponse).contains("\"website\":\"https://example.com\"");
+		assertThat(userInfoResponse).contains("\"email\":\"user1@example.com\"");
+		assertThat(userInfoResponse).contains("\"email_verified\":true");
+		assertThat(userInfoResponse).contains("\"gender\":\"female\"");
+		assertThat(userInfoResponse).contains("\"birthdate\":\"1970-01-01\"");
+		assertThat(userInfoResponse).contains("\"zoneinfo\":\"Europe/Paris\"");
+		assertThat(userInfoResponse).contains("\"locale\":\"en-US\"");
+		assertThat(userInfoResponse).contains("\"phone_number\":\"+1 (604) 555-1234;ext=5678\"");
+		assertThat(userInfoResponse).contains("\"phone_number_verified\":false");
+		assertThat(userInfoResponse).contains("\"address\":");
+		assertThat(userInfoResponse)
+			.contains("\"formatted\":\"Champ de Mars\\n5 Av. Anatole France\\n75007 Paris\\nFrance\"");
+		assertThat(userInfoResponse).contains("\"updated_at\":1607633867");
+		assertThat(userInfoResponse).contains("\"custom_claim\":\"value\"");
+		assertThat(userInfoResponse).contains("\"custom_collection_claim\":[\"value1\",\"value2\"]");
+	}
+
+	@Test
+	public void writeInternalWhenWriteFailsThenThrowsException() {
+		String errorMessage = "this is not a valid converter";
+		Converter<OidcUserInfo, Map<String, Object>> failingConverter = (source) -> {
+			throw new RuntimeException(errorMessage);
+		};
+		this.messageConverter.setUserInfoParametersConverter(failingConverter);
+
+		OidcUserInfo userInfo = createUserInfo();
+		MockHttpOutputMessage outputMessage = new MockHttpOutputMessage();
+
+		assertThatExceptionOfType(HttpMessageNotWritableException.class)
+			.isThrownBy(() -> this.messageConverter.writeInternal(userInfo, outputMessage))
+			.withMessageContaining("An error occurred writing the UserInfo response")
+			.withMessageContaining(errorMessage);
+	}
+
+	private static OidcUserInfo createUserInfo() {
+		return OidcUserInfo.builder()
+			.subject("user1")
+			.name("First Last")
+			.givenName("First")
+			.familyName("Last")
+			.middleName("Middle")
+			.nickname("User")
+			.preferredUsername("user")
+			.profile("https://example.com/user1")
+			.picture("https://example.com/user1.jpg")
+			.website("https://example.com")
+			.email("user1@example.com")
+			.emailVerified(true)
+			.gender("female")
+			.birthdate("1970-01-01")
+			.zoneinfo("Europe/Paris")
+			.locale("en-US")
+			.phoneNumber("+1 (604) 555-1234;ext=5678")
+			.claim("phone_number_verified", false)
+			.claim("address",
+					Collections.singletonMap("formatted", "Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance"))
+			.claim(StandardClaimNames.UPDATED_AT, Instant.ofEpochSecond(1607633867))
+			.claim("custom_claim", "value")
+			.claim("custom_collection_claim", Arrays.asList("value1", "value2"))
+			.build();
+	}
+
+}

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

@@ -0,0 +1,602 @@
+/*
+ * 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.time.Instant;
+import java.util.Collections;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+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.springframework.http.HttpStatus;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.mock.http.client.MockClientHttpRequest;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2Error;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationResponseType;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.core.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.TestJwsHeaders;
+import org.springframework.security.oauth2.jwt.TestJwtClaimsSets;
+import org.springframework.security.oauth2.server.authorization.oidc.OidcClientRegistration;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcClientRegistrationAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.oidc.http.converter.OidcClientRegistrationHttpMessageConverter;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+import org.springframework.security.web.authentication.AuthenticationConverter;
+import org.springframework.security.web.authentication.AuthenticationFailureHandler;
+import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OidcClientRegistrationEndpointFilter}.
+ *
+ * @author Ovidiu Popa
+ * @author Joe Grandja
+ * @author Daniel Garnier-Moiroux
+ */
+public class OidcClientRegistrationEndpointFilterTests {
+
+	private static final String DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI = "/connect/register";
+
+	private AuthenticationManager authenticationManager;
+
+	private OidcClientRegistrationEndpointFilter filter;
+
+	private final HttpMessageConverter<OidcClientRegistration> clientRegistrationHttpMessageConverter = new OidcClientRegistrationHttpMessageConverter();
+
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter();
+
+	@BeforeEach
+	public void setup() {
+		this.authenticationManager = mock(AuthenticationManager.class);
+		this.filter = new OidcClientRegistrationEndpointFilter(this.authenticationManager);
+	}
+
+	@AfterEach
+	public void cleanup() {
+		SecurityContextHolder.clearContext();
+	}
+
+	@Test
+	public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new OidcClientRegistrationEndpointFilter(null))
+			.withMessage("authenticationManager cannot be null");
+	}
+
+	@Test
+	public void constructorWhenClientRegistrationEndpointUriNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcClientRegistrationEndpointFilter(this.authenticationManager, null))
+			.withMessage("clientRegistrationEndpointUri cannot be empty");
+	}
+
+	@Test
+	public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationConverter(null))
+			.withMessage("authenticationConverter cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null))
+			.withMessage("authenticationSuccessHandler cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null))
+			.withMessage("authenticationFailureHandler cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenNotClientRegistrationRequestThenNotProcessed() throws Exception {
+		String requestUri = "/path";
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", 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 doFilterWhenClientRegistrationRequestGetThenNotProcessed() throws Exception {
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		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 doFilterWhenClientRegistrationRequestInvalidThenInvalidRequestError() throws Exception {
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+		request.setContent("invalid content".getBytes());
+		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());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+		assertThat(error.getDescription()).startsWith("OpenID Client Registration Error: ");
+	}
+
+	@Test
+	public void doFilterWhenClientRegistrationRequestInvalidTokenThenUnauthorizedError() throws Exception {
+		doFilterWhenClientRegistrationRequestInvalidThenError(OAuth2ErrorCodes.INVALID_TOKEN, HttpStatus.UNAUTHORIZED);
+	}
+
+	@Test
+	public void doFilterWhenClientRegistrationRequestInsufficientTokenScopeThenForbiddenError() throws Exception {
+		doFilterWhenClientRegistrationRequestInvalidThenError(OAuth2ErrorCodes.INSUFFICIENT_SCOPE,
+				HttpStatus.FORBIDDEN);
+	}
+
+	private void doFilterWhenClientRegistrationRequestInvalidThenError(String errorCode, HttpStatus status)
+			throws Exception {
+		Jwt jwt = createJwt("client.create");
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(principal);
+		SecurityContextHolder.setContext(securityContext);
+
+		given(this.authenticationManager.authenticate(any())).willThrow(new OAuth2AuthenticationException(errorCode));
+
+		// @formatter:off
+		OidcClientRegistration clientRegistrationRequest = OidcClientRegistration.builder()
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.scope("scope1")
+				.scope("scope2")
+				.build();
+		// @formatter:on
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+		writeClientRegistrationRequest(request, clientRegistrationRequest);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(status.value());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(errorCode);
+	}
+
+	@Test
+	public void doFilterWhenClientRegistrationRequestValidThenSuccessResponse() throws Exception {
+		// @formatter:off
+		OidcClientRegistration expectedClientRegistrationResponse = createClientRegistration();
+
+		OidcClientRegistration clientRegistrationRequest = OidcClientRegistration.builder()
+				.clientName(expectedClientRegistrationResponse.getClientName())
+				.redirectUris((redirectUris) -> redirectUris.addAll(expectedClientRegistrationResponse.getRedirectUris()))
+				.grantTypes((grantTypes) -> grantTypes.addAll(expectedClientRegistrationResponse.getGrantTypes()))
+				.scopes((scopes) -> scopes.addAll(expectedClientRegistrationResponse.getScopes()))
+				.build();
+		// @formatter:on
+
+		Jwt jwt = createJwt("client.create");
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.create"));
+
+		OidcClientRegistrationAuthenticationToken clientRegistrationAuthenticationResult = new OidcClientRegistrationAuthenticationToken(
+				principal, expectedClientRegistrationResponse);
+
+		given(this.authenticationManager.authenticate(any())).willReturn(clientRegistrationAuthenticationResult);
+
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(principal);
+		SecurityContextHolder.setContext(securityContext);
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
+		request.setServletPath(requestUri);
+		writeClientRegistrationRequest(request, clientRegistrationRequest);
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.CREATED.value());
+		OidcClientRegistration clientRegistrationResponse = readClientRegistrationResponse(response);
+		assertThat(clientRegistrationResponse.getClientId())
+			.isEqualTo(expectedClientRegistrationResponse.getClientId());
+		assertThat(clientRegistrationResponse.getClientIdIssuedAt()).isBetween(
+				expectedClientRegistrationResponse.getClientIdIssuedAt().minusSeconds(1),
+				expectedClientRegistrationResponse.getClientIdIssuedAt().plusSeconds(1));
+		assertThat(clientRegistrationResponse.getClientSecret())
+			.isEqualTo(expectedClientRegistrationResponse.getClientSecret());
+		assertThat(clientRegistrationResponse.getClientSecretExpiresAt())
+			.isEqualTo(expectedClientRegistrationResponse.getClientSecretExpiresAt());
+		assertThat(clientRegistrationResponse.getClientName())
+			.isEqualTo(expectedClientRegistrationResponse.getClientName());
+		assertThat(clientRegistrationResponse.getRedirectUris())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getRedirectUris());
+		assertThat(clientRegistrationResponse.getGrantTypes())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getGrantTypes());
+		assertThat(clientRegistrationResponse.getResponseTypes())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getResponseTypes());
+		assertThat(clientRegistrationResponse.getScopes())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getScopes());
+		assertThat(clientRegistrationResponse.getTokenEndpointAuthenticationMethod())
+			.isEqualTo(expectedClientRegistrationResponse.getTokenEndpointAuthenticationMethod());
+		assertThat(clientRegistrationResponse.getIdTokenSignedResponseAlgorithm())
+			.isEqualTo(expectedClientRegistrationResponse.getIdTokenSignedResponseAlgorithm());
+		assertThat(clientRegistrationResponse.getRegistrationAccessToken())
+			.isEqualTo(expectedClientRegistrationResponse.getRegistrationAccessToken());
+		assertThat(clientRegistrationResponse.getRegistrationClientUrl())
+			.isEqualTo(expectedClientRegistrationResponse.getRegistrationClientUrl());
+	}
+
+	@Test
+	public void doFilterWhenClientConfigurationRequestPutThenNotProcessed() throws Exception {
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("PUT", 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 doFilterWhenClientConfigurationRequestMissingClientIdThenNotProcessed() throws Exception {
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		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 doFilterWhenClientConfigurationRequestEmptyClientIdThenNotProcessed() throws Exception {
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "");
+		updateQueryString(request);
+		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 doFilterWhenClientConfigurationRequestMultipleClientIdThenInvalidRequestError() throws Exception {
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-id");
+		request.addParameter(OAuth2ParameterNames.CLIENT_ID, "client-id2");
+		updateQueryString(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());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(OAuth2ErrorCodes.INVALID_REQUEST);
+	}
+
+	@Test
+	public void doFilterWhenClientConfigurationRequestInvalidTokenThenUnauthorizedError() throws Exception {
+		doFilterWhenClientConfigurationRequestInvalidThenError(OAuth2ErrorCodes.INVALID_TOKEN, HttpStatus.UNAUTHORIZED);
+	}
+
+	@Test
+	public void doFilterWhenClientConfigurationRequestInsufficientScopeThenForbiddenError() throws Exception {
+		doFilterWhenClientConfigurationRequestInvalidThenError(OAuth2ErrorCodes.INSUFFICIENT_SCOPE,
+				HttpStatus.FORBIDDEN);
+	}
+
+	@Test
+	public void doFilterWhenClientConfigurationRequestInvalidClientThenUnauthorizedError() throws Exception {
+		doFilterWhenClientConfigurationRequestInvalidThenError(OAuth2ErrorCodes.INVALID_CLIENT,
+				HttpStatus.UNAUTHORIZED);
+	}
+
+	private void doFilterWhenClientConfigurationRequestInvalidThenError(String errorCode, HttpStatus status)
+			throws Exception {
+		Jwt jwt = createJwt("client.read");
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(principal);
+		SecurityContextHolder.setContext(securityContext);
+
+		given(this.authenticationManager.authenticate(any())).willThrow(new OAuth2AuthenticationException(errorCode));
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client1");
+		updateQueryString(request);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(status.value());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(errorCode);
+	}
+
+	@Test
+	public void doFilterWhenClientConfigurationRequestValidThenSuccessResponse() throws Exception {
+		OidcClientRegistration expectedClientRegistrationResponse = createClientRegistration();
+
+		Jwt jwt = createJwt("client.read");
+		JwtAuthenticationToken principal = new JwtAuthenticationToken(jwt,
+				AuthorityUtils.createAuthorityList("SCOPE_client.read"));
+
+		OidcClientRegistrationAuthenticationToken clientConfigurationAuthenticationResult = new OidcClientRegistrationAuthenticationToken(
+				principal, expectedClientRegistrationResponse);
+
+		given(this.authenticationManager.authenticate(any())).willReturn(clientConfigurationAuthenticationResult);
+
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(principal);
+		SecurityContextHolder.setContext(securityContext);
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.setParameter(OAuth2ParameterNames.CLIENT_ID, expectedClientRegistrationResponse.getClientId());
+		updateQueryString(request);
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(HttpStatus.OK.value());
+		OidcClientRegistration clientRegistrationResponse = readClientRegistrationResponse(response);
+		assertThat(clientRegistrationResponse.getClientId())
+			.isEqualTo(expectedClientRegistrationResponse.getClientId());
+		assertThat(clientRegistrationResponse.getClientIdIssuedAt()).isBetween(
+				expectedClientRegistrationResponse.getClientIdIssuedAt().minusSeconds(1),
+				expectedClientRegistrationResponse.getClientIdIssuedAt().plusSeconds(1));
+		assertThat(clientRegistrationResponse.getClientSecret())
+			.isEqualTo(expectedClientRegistrationResponse.getClientSecret());
+		assertThat(clientRegistrationResponse.getClientSecretExpiresAt())
+			.isEqualTo(expectedClientRegistrationResponse.getClientSecretExpiresAt());
+		assertThat(clientRegistrationResponse.getClientName())
+			.isEqualTo(expectedClientRegistrationResponse.getClientName());
+		assertThat(clientRegistrationResponse.getRedirectUris())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getRedirectUris());
+		assertThat(clientRegistrationResponse.getGrantTypes())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getGrantTypes());
+		assertThat(clientRegistrationResponse.getResponseTypes())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getResponseTypes());
+		assertThat(clientRegistrationResponse.getScopes())
+			.containsExactlyInAnyOrderElementsOf(expectedClientRegistrationResponse.getScopes());
+		assertThat(clientRegistrationResponse.getTokenEndpointAuthenticationMethod())
+			.isEqualTo(expectedClientRegistrationResponse.getTokenEndpointAuthenticationMethod());
+		assertThat(clientRegistrationResponse.getIdTokenSignedResponseAlgorithm())
+			.isEqualTo(expectedClientRegistrationResponse.getIdTokenSignedResponseAlgorithm());
+		assertThat(clientRegistrationResponse.getRegistrationClientUrl())
+			.isEqualTo(expectedClientRegistrationResponse.getRegistrationClientUrl());
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationConverterThenUsed() throws ServletException, IOException {
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client-id");
+		updateQueryString(request);
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(authenticationConverter).convert(request);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception {
+		OidcClientRegistration expectedClientRegistrationResponse = createClientRegistration();
+		Authentication principal = new TestingAuthenticationToken("principal", "Credentials");
+
+		OidcClientRegistrationAuthenticationToken clientRegistrationAuthenticationResult = new OidcClientRegistrationAuthenticationToken(
+				principal, expectedClientRegistrationResponse);
+
+		given(this.authenticationManager.authenticate(any())).willReturn(clientRegistrationAuthenticationResult);
+		AuthenticationSuccessHandler successHandler = mock(AuthenticationSuccessHandler.class);
+		this.filter.setAuthenticationSuccessHandler(successHandler);
+
+		SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+		securityContext.setAuthentication(principal);
+		SecurityContextHolder.setContext(securityContext);
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.setParameter(OAuth2ParameterNames.CLIENT_ID, expectedClientRegistrationResponse.getClientId());
+		updateQueryString(request);
+
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(successHandler).onAuthenticationSuccess(request, response, clientRegistrationAuthenticationResult);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exception {
+		AuthenticationFailureHandler authenticationFailureHandler = mock(AuthenticationFailureHandler.class);
+		this.filter.setAuthenticationFailureHandler(authenticationFailureHandler);
+
+		given(this.authenticationManager.authenticate(any()))
+			.willThrow(new OAuth2AuthenticationException(OAuth2ErrorCodes.INVALID_TOKEN));
+
+		String requestUri = DEFAULT_OIDC_CLIENT_REGISTRATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		request.setParameter(OAuth2ParameterNames.CLIENT_ID, "client1");
+		updateQueryString(request);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(authenticationFailureHandler).onAuthenticationFailure(eq(request), eq(response),
+				any(OAuth2AuthenticationException.class));
+	}
+
+	private static void updateQueryString(MockHttpServletRequest request) {
+		UriComponentsBuilder uriBuilder = UriComponentsBuilder.fromUriString(request.getRequestURI());
+		request.getParameterMap().forEach((key, values) -> {
+			if (values.length > 0) {
+				for (String value : values) {
+					uriBuilder.queryParam(key, value);
+				}
+			}
+		});
+		request.setQueryString(uriBuilder.build().getQuery());
+	}
+
+	private OAuth2Error readError(MockHttpServletResponse response) throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse);
+	}
+
+	private void writeClientRegistrationRequest(MockHttpServletRequest request,
+			OidcClientRegistration clientRegistration) throws Exception {
+		MockClientHttpRequest httpRequest = new MockClientHttpRequest();
+		this.clientRegistrationHttpMessageConverter.write(clientRegistration, null, httpRequest);
+		request.setContent(httpRequest.getBodyAsBytes());
+	}
+
+	private OidcClientRegistration readClientRegistrationResponse(MockHttpServletResponse response) throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return this.clientRegistrationHttpMessageConverter.read(OidcClientRegistration.class, httpResponse);
+	}
+
+	private static OidcClientRegistration createClientRegistration() {
+		// @formatter:off
+		OidcClientRegistration clientRegistration = OidcClientRegistration.builder()
+				.clientId("client-id")
+				.clientIdIssuedAt(Instant.now())
+				.clientSecret("client-secret")
+				.clientName("client-name")
+				.redirectUri("https://client.example.com")
+				.grantType(AuthorizationGrantType.AUTHORIZATION_CODE.getValue())
+				.grantType(AuthorizationGrantType.CLIENT_CREDENTIALS.getValue())
+				.tokenEndpointAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC.getValue())
+				.responseType(OAuth2AuthorizationResponseType.CODE.getValue())
+				.idTokenSignedResponseAlgorithm(SignatureAlgorithm.RS256.getName())
+				.scope("scope1")
+				.scope("scope2")
+				.registrationClientUrl("https://auth-server:9000/connect/register?client_id=client-id")
+				.build();
+		return clientRegistration;
+		// @formatter:on
+	}
+
+	private static Jwt createJwt(String scope) {
+		// @formatter:off
+		JwsHeader jwsHeader = TestJwsHeaders.jwsHeader()
+				.build();
+		JwtClaimsSet jwtClaimsSet = TestJwtClaimsSets.jwtClaimsSet()
+				.claim(OAuth2ParameterNames.SCOPE, Collections.singleton(scope))
+				.build();
+		Jwt jwt = Jwt.withTokenValue("jwt-access-token")
+				.headers((headers) -> headers.putAll(jwsHeader.getHeaders()))
+				.claims((claims) -> claims.putAll(jwtClaimsSet.getClaims()))
+				.build();
+		// @formatter:on
+		return jwt;
+	}
+
+}

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

@@ -0,0 +1,348 @@
+/*
+ * 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.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.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.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * 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");
+		given(this.authenticationManager.authenticate(any())).willThrow(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,
+				null, null, null, null);
+
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		given(authenticationConverter.convert(any())).willReturn((authentication));
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		given(this.authenticationManager.authenticate(any())).willReturn((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,
+				null, null, null, null);
+
+		AuthenticationSuccessHandler authenticationSuccessHandler = mock(AuthenticationSuccessHandler.class);
+		this.filter.setAuthenticationSuccessHandler(authenticationSuccessHandler);
+
+		given(this.authenticationManager.authenticate(any())).willReturn((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);
+
+		given(this.authenticationManager.authenticate(any()))
+			.willThrow(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);
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken("id-token", this.principal,
+				session.getId(), null, null, null);
+
+		given(this.authenticationManager.authenticate(any())).willReturn((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);
+
+		String postLogoutRedirectUri = registeredClient.getPostLogoutRedirectUris().iterator().next();
+		String state = "state-1";
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken("id-token", this.principal,
+				session.getId(), registeredClient.getClientId(), postLogoutRedirectUri, state);
+		authentication.setAuthenticated(true);
+
+		given(this.authenticationManager.authenticate(any())).willReturn((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;
+	}
+
+}

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

@@ -0,0 +1,184 @@
+/*
+ * Copyright 2020-2025 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 jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.MediaType;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+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 org.springframework.web.util.InvalidUrlException;
+
+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.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OidcProviderConfigurationEndpointFilter}.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
+ */
+public class OidcProviderConfigurationEndpointFilterTests {
+
+	private static final String DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI = "/.well-known/openid-configuration";
+
+	private final OidcProviderConfigurationEndpointFilter filter = new OidcProviderConfigurationEndpointFilter();
+
+	@AfterEach
+	public void cleanup() {
+		AuthorizationServerContextHolder.resetContext();
+	}
+
+	@Test
+	public void setProviderConfigurationCustomizerWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.filter.setProviderConfigurationCustomizer(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("providerConfigurationCustomizer cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenNotConfigurationRequestThenNotProcessed() throws Exception {
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(AuthorizationServerSettings.builder().build(), null));
+
+		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 doFilterWhenConfigurationRequestPostThenNotProcessed() throws Exception {
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(AuthorizationServerSettings.builder().build(), null));
+
+		String requestUri = DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("POST", 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 doFilterWhenConfigurationRequestThenConfigurationResponse() throws Exception {
+		String issuer = "https://example.com";
+		String authorizationEndpoint = "/oauth2/v1/authorize";
+		String pushedAuthorizationRequestEndpoint = "/oauth2/v1/par";
+		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";
+
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer(issuer)
+			.authorizationEndpoint(authorizationEndpoint)
+			.pushedAuthorizationRequestEndpoint(pushedAuthorizationRequestEndpoint)
+			.tokenEndpoint(tokenEndpoint)
+			.jwkSetEndpoint(jwkSetEndpoint)
+			.oidcUserInfoEndpoint(userInfoEndpoint)
+			.oidcLogoutEndpoint(logoutEndpoint)
+			.tokenRevocationEndpoint(tokenRevocationEndpoint)
+			.tokenIntrospectionEndpoint(tokenIntrospectionEndpoint)
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
+
+		String requestUri = DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
+		String providerConfigurationResponse = response.getContentAsString();
+		assertThat(providerConfigurationResponse).contains("\"issuer\":\"https://example.com\"");
+		assertThat(providerConfigurationResponse)
+			.contains("\"authorization_endpoint\":\"https://example.com/oauth2/v1/authorize\"");
+		assertThat(providerConfigurationResponse)
+			.contains("\"pushed_authorization_request_endpoint\":\"https://example.com/oauth2/v1/par\"");
+		assertThat(providerConfigurationResponse)
+			.contains("\"token_endpoint\":\"https://example.com/oauth2/v1/token\"");
+		assertThat(providerConfigurationResponse).contains("\"jwks_uri\":\"https://example.com/oauth2/v1/jwks\"");
+		assertThat(providerConfigurationResponse).contains("\"scopes_supported\":[\"openid\"]");
+		assertThat(providerConfigurationResponse).contains("\"response_types_supported\":[\"code\"]");
+		assertThat(providerConfigurationResponse).contains(
+				"\"grant_types_supported\":[\"authorization_code\",\"client_credentials\",\"refresh_token\",\"urn:ietf:params:oauth:grant-type:device_code\",\"urn:ietf:params:oauth:grant-type:token-exchange\"]");
+		assertThat(providerConfigurationResponse)
+			.contains("\"revocation_endpoint\":\"https://example.com/oauth2/v1/revoke\"");
+		assertThat(providerConfigurationResponse).contains(
+				"\"revocation_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
+		assertThat(providerConfigurationResponse)
+			.contains("\"introspection_endpoint\":\"https://example.com/oauth2/v1/introspect\"");
+		assertThat(providerConfigurationResponse).contains(
+				"\"introspection_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
+		assertThat(providerConfigurationResponse).contains("\"code_challenge_methods_supported\":[\"S256\"]");
+		assertThat(providerConfigurationResponse).contains("\"tls_client_certificate_bound_access_tokens\":true");
+		assertThat(providerConfigurationResponse).contains(
+				"\"dpop_signing_alg_values_supported\":[\"RS256\",\"RS384\",\"RS512\",\"PS256\",\"PS384\",\"PS512\",\"ES256\",\"ES384\",\"ES512\"]");
+		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/userinfo\"");
+		assertThat(providerConfigurationResponse)
+			.contains("\"end_session_endpoint\":\"https://example.com/connect/logout\"");
+		assertThat(providerConfigurationResponse).contains(
+				"\"token_endpoint_auth_methods_supported\":[\"client_secret_basic\",\"client_secret_post\",\"client_secret_jwt\",\"private_key_jwt\",\"tls_client_auth\",\"self_signed_tls_client_auth\"]");
+	}
+
+	@Test
+	public void doFilterWhenAuthorizationServerSettingsWithInvalidIssuerThenThrowIllegalArgumentException() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://this is an invalid URL")
+			.build();
+		AuthorizationServerContextHolder
+			.setContext(new TestAuthorizationServerContext(authorizationServerSettings, null));
+
+		String requestUri = DEFAULT_OIDC_PROVIDER_CONFIGURATION_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		assertThatThrownBy(() -> this.filter.doFilter(request, response, filterChain))
+			.isInstanceOf(InvalidUrlException.class);
+	}
+
+}

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

@@ -0,0 +1,344 @@
+/*
+ * Copyright 2020-2022 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.Collections;
+
+import jakarta.servlet.FilterChain;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.converter.HttpMessageConverter;
+import org.springframework.mock.http.client.MockClientHttpResponse;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.authentication.AuthenticationManager;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+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.http.converter.OAuth2ErrorHttpMessageConverter;
+import org.springframework.security.oauth2.core.oidc.OidcUserInfo;
+import org.springframework.security.oauth2.core.oidc.StandardClaimNames;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JoseHeaderNames;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcUserInfoAuthenticationToken;
+import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
+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.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+/**
+ * Tests for {@link OidcUserInfoEndpointFilter}.
+ *
+ * @author Steve Riesenberg
+ */
+public class OidcUserInfoEndpointFilterTests {
+
+	private static final String DEFAULT_OIDC_USER_INFO_ENDPOINT_URI = "/userinfo";
+
+	private AuthenticationManager authenticationManager;
+
+	private OidcUserInfoEndpointFilter filter;
+
+	private final HttpMessageConverter<OAuth2Error> errorHttpResponseConverter = new OAuth2ErrorHttpMessageConverter();
+
+	@BeforeEach
+	public void setup() {
+		this.authenticationManager = mock(AuthenticationManager.class);
+		this.filter = new OidcUserInfoEndpointFilter(this.authenticationManager, DEFAULT_OIDC_USER_INFO_ENDPOINT_URI);
+	}
+
+	@Test
+	public void constructorWhenAuthenticationManagerNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> new OidcUserInfoEndpointFilter(null))
+			.withMessage("authenticationManager cannot be null");
+	}
+
+	@Test
+	public void constructorWhenUserInfoEndpointUriIsEmptyThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> new OidcUserInfoEndpointFilter(this.authenticationManager, ""))
+			.withMessage("userInfoEndpointUri cannot be empty");
+	}
+
+	@Test
+	public void setAuthenticationConverterWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationConverter(null))
+			.withMessage("authenticationConverter cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationSuccessHandlerWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationSuccessHandler(null))
+			.withMessage("authenticationSuccessHandler cannot be null");
+	}
+
+	@Test
+	public void setAuthenticationFailureHandlerWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> this.filter.setAuthenticationFailureHandler(null))
+			.withMessage("authenticationFailureHandler cannot be null");
+	}
+
+	@Test
+	public void doFilterWhenNotUserInfoRequestThenNotProcessed() 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(request, response);
+	}
+
+	@Test
+	public void doFilterWhenUserInfoRequestPutThenNotProcessed() throws Exception {
+		String requestUri = DEFAULT_OIDC_USER_INFO_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("PUT", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(this.authenticationManager);
+		verify(filterChain).doFilter(request, response);
+	}
+
+	@Test
+	public void doFilterWhenUserInfoRequestGetThenSuccess() throws Exception {
+		doFilterWhenUserInfoRequestThenSuccess("GET");
+	}
+
+	@Test
+	public void doFilterWhenUserInfoRequestPostThenSuccess() throws Exception {
+		doFilterWhenUserInfoRequestThenSuccess("POST");
+	}
+
+	private void doFilterWhenUserInfoRequestThenSuccess(String httpMethod) throws Exception {
+		JwtAuthenticationToken principal = createJwtAuthenticationToken();
+		SecurityContextHolder.getContext().setAuthentication(principal);
+
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal,
+				createUserInfo());
+		given(this.authenticationManager.authenticate(any())).willReturn(authentication);
+
+		String requestUri = DEFAULT_OIDC_USER_INFO_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest(httpMethod, requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verify(this.authenticationManager).authenticate(any());
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getContentType()).isEqualTo(MediaType.APPLICATION_JSON_VALUE);
+		assertUserInfoResponse(response.getContentAsString());
+	}
+
+	@Test
+	public void doFilterWhenUserInfoRequestInvalidTokenThenUnauthorizedError() throws Exception {
+		doFilterWhenAuthenticationExceptionThenError(OAuth2ErrorCodes.INVALID_TOKEN, HttpStatus.UNAUTHORIZED);
+	}
+
+	@Test
+	public void doFilterWhenUserInfoRequestInsufficientScopeThenForbiddenError() throws Exception {
+		doFilterWhenAuthenticationExceptionThenError(OAuth2ErrorCodes.INSUFFICIENT_SCOPE, HttpStatus.FORBIDDEN);
+	}
+
+	private void doFilterWhenAuthenticationExceptionThenError(String oauth2ErrorCode, HttpStatus httpStatus)
+			throws Exception {
+		Authentication principal = new TestingAuthenticationToken("principal", "credentials");
+		SecurityContextHolder.getContext().setAuthentication(principal);
+
+		given(this.authenticationManager.authenticate(any()))
+			.willThrow(new OAuth2AuthenticationException(oauth2ErrorCode));
+
+		String requestUri = DEFAULT_OIDC_USER_INFO_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+
+		assertThat(response.getStatus()).isEqualTo(httpStatus.value());
+		OAuth2Error error = readError(response);
+		assertThat(error.getErrorCode()).isEqualTo(oauth2ErrorCode);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationConverterThenUsed() throws Exception {
+		Authentication principal = new TestingAuthenticationToken("principal", "credentials");
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal);
+		AuthenticationConverter authenticationConverter = mock(AuthenticationConverter.class);
+		this.filter.setAuthenticationConverter(authenticationConverter);
+
+		given(authenticationConverter.convert(any())).willReturn(authentication);
+		given(this.authenticationManager.authenticate(any()))
+			.willReturn(new OidcUserInfoAuthenticationToken(principal, createUserInfo()));
+
+		String requestUri = DEFAULT_OIDC_USER_INFO_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+		verify(authenticationConverter).convert(request);
+		verify(this.authenticationManager).authenticate(authentication);
+		assertUserInfoResponse(response.getContentAsString());
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationSuccessHandlerThenUsed() throws Exception {
+		AuthenticationSuccessHandler successHandler = mock(AuthenticationSuccessHandler.class);
+		this.filter.setAuthenticationSuccessHandler(successHandler);
+
+		Authentication principal = new TestingAuthenticationToken("principal", "credentials");
+		SecurityContextHolder.getContext().setAuthentication(principal);
+
+		OidcUserInfoAuthenticationToken authentication = new OidcUserInfoAuthenticationToken(principal,
+				createUserInfo());
+		given(this.authenticationManager.authenticate(any())).willReturn(authentication);
+
+		String requestUri = DEFAULT_OIDC_USER_INFO_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+		verify(successHandler).onAuthenticationSuccess(request, response, authentication);
+	}
+
+	@Test
+	public void doFilterWhenCustomAuthenticationFailureHandlerThenUsed() throws Exception {
+		AuthenticationFailureHandler failureHandler = mock(AuthenticationFailureHandler.class);
+		this.filter.setAuthenticationFailureHandler(failureHandler);
+
+		Authentication principal = new TestingAuthenticationToken("principal", "credentials");
+		SecurityContextHolder.getContext().setAuthentication(principal);
+
+		OAuth2AuthenticationException authenticationException = new OAuth2AuthenticationException(
+				OAuth2ErrorCodes.INVALID_TOKEN);
+		given(this.authenticationManager.authenticate(any())).willThrow(authenticationException);
+
+		String requestUri = DEFAULT_OIDC_USER_INFO_ENDPOINT_URI;
+		MockHttpServletRequest request = new MockHttpServletRequest("GET", requestUri);
+		request.setServletPath(requestUri);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+		FilterChain filterChain = mock(FilterChain.class);
+
+		this.filter.doFilter(request, response, filterChain);
+
+		verifyNoInteractions(filterChain);
+		verify(failureHandler).onAuthenticationFailure(request, response, authenticationException);
+	}
+
+	private OAuth2Error readError(MockHttpServletResponse response) throws Exception {
+		MockClientHttpResponse httpResponse = new MockClientHttpResponse(response.getContentAsByteArray(),
+				HttpStatus.valueOf(response.getStatus()));
+		return this.errorHttpResponseConverter.read(OAuth2Error.class, httpResponse);
+	}
+
+	private JwtAuthenticationToken createJwtAuthenticationToken() {
+		Instant now = Instant.now();
+		// @formatter:off
+		Jwt jwt = Jwt.withTokenValue("token")
+				.header(JoseHeaderNames.ALG, SignatureAlgorithm.RS256.getName())
+				.issuedAt(now)
+				.expiresAt(now.plusSeconds(300))
+				.claim(StandardClaimNames.SUB, "user")
+				.build();
+		// @formatter:on
+		return new JwtAuthenticationToken(jwt, Collections.emptyList());
+	}
+
+	private static OidcUserInfo createUserInfo() {
+		return OidcUserInfo.builder()
+			.subject("user1")
+			.name("First Last")
+			.givenName("First")
+			.familyName("Last")
+			.middleName("Middle")
+			.nickname("User")
+			.preferredUsername("user")
+			.profile("https://example.com/user1")
+			.picture("https://example.com/user1.jpg")
+			.website("https://example.com")
+			.email("user1@example.com")
+			.emailVerified(true)
+			.gender("female")
+			.birthdate("1970-01-01")
+			.zoneinfo("Europe/Paris")
+			.locale("en-US")
+			.phoneNumber("+1 (604) 555-1234;ext=5678")
+			.phoneNumberVerified(false)
+			.address("Champ de Mars\n5 Av. Anatole France\n75007 Paris\nFrance")
+			.updatedAt("1970-01-01T00:00:00Z")
+			.build();
+	}
+
+	private static void assertUserInfoResponse(String userInfoResponse) {
+		assertThat(userInfoResponse).contains("\"sub\":\"user1\"");
+		assertThat(userInfoResponse).contains("\"name\":\"First Last\"");
+		assertThat(userInfoResponse).contains("\"given_name\":\"First\"");
+		assertThat(userInfoResponse).contains("\"family_name\":\"Last\"");
+		assertThat(userInfoResponse).contains("\"middle_name\":\"Middle\"");
+		assertThat(userInfoResponse).contains("\"nickname\":\"User\"");
+		assertThat(userInfoResponse).contains("\"preferred_username\":\"user\"");
+		assertThat(userInfoResponse).contains("\"profile\":\"https://example.com/user1\"");
+		assertThat(userInfoResponse).contains("\"picture\":\"https://example.com/user1.jpg\"");
+		assertThat(userInfoResponse).contains("\"website\":\"https://example.com\"");
+		assertThat(userInfoResponse).contains("\"email\":\"user1@example.com\"");
+		assertThat(userInfoResponse).contains("\"email_verified\":true");
+		assertThat(userInfoResponse).contains("\"gender\":\"female\"");
+		assertThat(userInfoResponse).contains("\"birthdate\":\"1970-01-01\"");
+		assertThat(userInfoResponse).contains("\"zoneinfo\":\"Europe/Paris\"");
+		assertThat(userInfoResponse).contains("\"locale\":\"en-US\"");
+		assertThat(userInfoResponse).contains("\"phone_number\":\"+1 (604) 555-1234;ext=5678\"");
+		assertThat(userInfoResponse).contains("\"phone_number_verified\":false");
+		assertThat(userInfoResponse)
+			.contains("\"address\":\"Champ de Mars\\n5 Av. Anatole France\\n75007 Paris\\nFrance\"");
+		assertThat(userInfoResponse).contains("\"updated_at\":\"1970-01-01T00:00:00Z\"");
+	}
+
+}

+ 94 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/oidc/web/authentication/OidcLogoutAuthenticationSuccessHandlerTests.java

@@ -0,0 +1,94 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.oidc.web.authentication;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.mock.web.MockHttpSession;
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
+import org.springframework.security.oauth2.core.OAuth2ErrorCodes;
+import org.springframework.security.oauth2.server.authorization.oidc.authentication.OidcLogoutAuthenticationToken;
+import org.springframework.security.web.authentication.logout.LogoutHandler;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OidcLogoutAuthenticationSuccessHandler}.
+ *
+ * @author Joe Grandja
+ */
+public class OidcLogoutAuthenticationSuccessHandlerTests {
+
+	private TestingAuthenticationToken principal;
+
+	private final OidcLogoutAuthenticationSuccessHandler authenticationSuccessHandler = new OidcLogoutAuthenticationSuccessHandler();
+
+	@BeforeEach
+	public void setUp() {
+		this.principal = new TestingAuthenticationToken("principal", "credentials");
+		this.principal.setAuthenticated(true);
+	}
+
+	@Test
+	public void setLogoutHandlerWhenNullThenThrowIllegalArgumentException() {
+		// @formatter:off
+		assertThatThrownBy(() -> this.authenticationSuccessHandler.setLogoutHandler(null))
+				.isInstanceOf(IllegalArgumentException.class)
+				.hasMessage("logoutHandler cannot be null");
+		// @formatter:on
+	}
+
+	@Test
+	public void onAuthenticationSuccessWhenInvalidAuthenticationTypeThenThrowOAuth2AuthenticationException() {
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpServletResponse response = new MockHttpServletResponse();
+
+		assertThatThrownBy(
+				() -> this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, this.principal))
+			.isInstanceOf(OAuth2AuthenticationException.class)
+			.extracting((ex) -> ((OAuth2AuthenticationException) ex).getError())
+			.extracting("errorCode")
+			.isEqualTo(OAuth2ErrorCodes.SERVER_ERROR);
+	}
+
+	@Test
+	public void onAuthenticationSuccessWhenLogoutHandlerSetThenUsed() throws Exception {
+		LogoutHandler logoutHandler = mock(LogoutHandler.class);
+		this.authenticationSuccessHandler.setLogoutHandler(logoutHandler);
+
+		MockHttpServletRequest request = new MockHttpServletRequest();
+		MockHttpSession session = (MockHttpSession) request.getSession(true);
+		MockHttpServletResponse response = new MockHttpServletResponse();
+
+		OidcLogoutAuthenticationToken authentication = new OidcLogoutAuthenticationToken("id-token", this.principal,
+				session.getId(), null, null, null);
+		this.authenticationSuccessHandler.onAuthenticationSuccess(request, response, authentication);
+
+		verify(logoutHandler).logout(any(HttpServletRequest.class), any(HttpServletResponse.class),
+				any(Authentication.class));
+	}
+
+}

+ 198 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettingsTests.java

@@ -0,0 +1,198 @@
+/*
+ * Copyright 2020-2025 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.settings;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link AuthorizationServerSettings}.
+ *
+ * @author Daniel Garnier-Moiroux
+ * @author Joe Grandja
+ */
+public class AuthorizationServerSettingsTests {
+
+	@Test
+	public void buildWhenDefaultThenDefaultsAreSet() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder().build();
+
+		assertThat(authorizationServerSettings.getIssuer()).isNull();
+		assertThat(authorizationServerSettings.isMultipleIssuersAllowed()).isFalse();
+		assertThat(authorizationServerSettings.getAuthorizationEndpoint()).isEqualTo("/oauth2/authorize");
+		assertThat(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()).isEqualTo("/oauth2/par");
+		assertThat(authorizationServerSettings.getTokenEndpoint()).isEqualTo("/oauth2/token");
+		assertThat(authorizationServerSettings.getJwkSetEndpoint()).isEqualTo("/oauth2/jwks");
+		assertThat(authorizationServerSettings.getTokenRevocationEndpoint()).isEqualTo("/oauth2/revoke");
+		assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect");
+		assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint()).isEqualTo("/connect/register");
+		assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo("/userinfo");
+		assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo("/connect/logout");
+	}
+
+	@Test
+	public void buildWhenSettingsProvidedThenSet() {
+		String authorizationEndpoint = "/oauth2/v1/authorize";
+		String pushedAuthorizationRequestEndpoint = "/oauth2/v1/par";
+		String tokenEndpoint = "/oauth2/v1/token";
+		String jwkSetEndpoint = "/oauth2/v1/jwks";
+		String tokenRevocationEndpoint = "/oauth2/v1/revoke";
+		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()
+			.issuer(issuer)
+			.authorizationEndpoint(authorizationEndpoint)
+			.pushedAuthorizationRequestEndpoint(pushedAuthorizationRequestEndpoint)
+			.tokenEndpoint(tokenEndpoint)
+			.jwkSetEndpoint(jwkSetEndpoint)
+			.tokenRevocationEndpoint(tokenRevocationEndpoint)
+			.tokenIntrospectionEndpoint(tokenIntrospectionEndpoint)
+			.tokenRevocationEndpoint(tokenRevocationEndpoint)
+			.oidcClientRegistrationEndpoint(oidcClientRegistrationEndpoint)
+			.oidcUserInfoEndpoint(oidcUserInfoEndpoint)
+			.oidcLogoutEndpoint(oidcLogoutEndpoint)
+			.build();
+
+		assertThat(authorizationServerSettings.getIssuer()).isEqualTo(issuer);
+		assertThat(authorizationServerSettings.isMultipleIssuersAllowed()).isFalse();
+		assertThat(authorizationServerSettings.getAuthorizationEndpoint()).isEqualTo(authorizationEndpoint);
+		assertThat(authorizationServerSettings.getPushedAuthorizationRequestEndpoint())
+			.isEqualTo(pushedAuthorizationRequestEndpoint);
+		assertThat(authorizationServerSettings.getTokenEndpoint()).isEqualTo(tokenEndpoint);
+		assertThat(authorizationServerSettings.getJwkSetEndpoint()).isEqualTo(jwkSetEndpoint);
+		assertThat(authorizationServerSettings.getTokenRevocationEndpoint()).isEqualTo(tokenRevocationEndpoint);
+		assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo(tokenIntrospectionEndpoint);
+		assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint())
+			.isEqualTo(oidcClientRegistrationEndpoint);
+		assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo(oidcUserInfoEndpoint);
+		assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo(oidcLogoutEndpoint);
+	}
+
+	@Test
+	public void buildWhenIssuerSetAndMultipleIssuersAllowedTrueThenThrowIllegalArgumentException() {
+		String issuer = "https://example.com:9000";
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().issuer(issuer).multipleIssuersAllowed(true).build())
+			.withMessage(
+					"The issuer identifier (" + issuer + ") cannot be set when isMultipleIssuersAllowed() is true.");
+	}
+
+	@Test
+	public void buildWhenIssuerNotSetAndMultipleIssuersAllowedTrueThenDefaultsAreSet() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.multipleIssuersAllowed(true)
+			.build();
+
+		assertThat(authorizationServerSettings.getIssuer()).isNull();
+		assertThat(authorizationServerSettings.isMultipleIssuersAllowed()).isTrue();
+		assertThat(authorizationServerSettings.getAuthorizationEndpoint()).isEqualTo("/oauth2/authorize");
+		assertThat(authorizationServerSettings.getPushedAuthorizationRequestEndpoint()).isEqualTo("/oauth2/par");
+		assertThat(authorizationServerSettings.getTokenEndpoint()).isEqualTo("/oauth2/token");
+		assertThat(authorizationServerSettings.getJwkSetEndpoint()).isEqualTo("/oauth2/jwks");
+		assertThat(authorizationServerSettings.getTokenRevocationEndpoint()).isEqualTo("/oauth2/revoke");
+		assertThat(authorizationServerSettings.getTokenIntrospectionEndpoint()).isEqualTo("/oauth2/introspect");
+		assertThat(authorizationServerSettings.getOidcClientRegistrationEndpoint()).isEqualTo("/connect/register");
+		assertThat(authorizationServerSettings.getOidcUserInfoEndpoint()).isEqualTo("/userinfo");
+		assertThat(authorizationServerSettings.getOidcLogoutEndpoint()).isEqualTo("/connect/logout");
+	}
+
+	@Test
+	public void settingWhenCustomThenSet() {
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.setting("name1", "value1")
+			.settings((settings) -> settings.put("name2", "value2"))
+			.build();
+
+		assertThat(authorizationServerSettings.getSettings()).hasSize(14);
+		assertThat(authorizationServerSettings.<String>getSetting("name1")).isEqualTo("value1");
+		assertThat(authorizationServerSettings.<String>getSetting("name2")).isEqualTo("value2");
+	}
+
+	@Test
+	public void issuerWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> AuthorizationServerSettings.builder().issuer(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void authorizationEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().authorizationEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void pushedAuthorizationRequestEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().pushedAuthorizationRequestEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void tokenEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException().isThrownBy(() -> AuthorizationServerSettings.builder().tokenEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void tokenRevocationEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().tokenRevocationEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void tokenIntrospectionEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().tokenIntrospectionEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void oidcClientRegistrationEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().oidcClientRegistrationEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void oidcUserInfoEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().oidcUserInfoEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void jwksEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().jwkSetEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+	@Test
+	public void oidcLogoutEndpointWhenNullThenThrowIllegalArgumentException() {
+		assertThatIllegalArgumentException()
+			.isThrownBy(() -> AuthorizationServerSettings.builder().oidcLogoutEndpoint(null))
+			.withMessage("value cannot be null");
+	}
+
+}

+ 85 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/ClientSettingsTests.java

@@ -0,0 +1,85 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.settings;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link ClientSettings}.
+ *
+ * @author Joe Grandja
+ */
+public class ClientSettingsTests {
+
+	@Test
+	public void buildWhenDefaultThenDefaultsAreSet() {
+		ClientSettings clientSettings = ClientSettings.builder().build();
+		assertThat(clientSettings.getSettings()).hasSize(2);
+		assertThat(clientSettings.isRequireProofKey()).isFalse();
+		assertThat(clientSettings.isRequireAuthorizationConsent()).isFalse();
+	}
+
+	@Test
+	public void requireProofKeyWhenTrueThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder().requireProofKey(true).build();
+		assertThat(clientSettings.isRequireProofKey()).isTrue();
+	}
+
+	@Test
+	public void requireAuthorizationConsentWhenTrueThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder().requireAuthorizationConsent(true).build();
+		assertThat(clientSettings.isRequireAuthorizationConsent()).isTrue();
+	}
+
+	@Test
+	public void tokenEndpointAuthenticationSigningAlgorithmWhenHS256ThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder()
+			.tokenEndpointAuthenticationSigningAlgorithm(MacAlgorithm.HS256)
+			.build();
+		assertThat(clientSettings.getTokenEndpointAuthenticationSigningAlgorithm()).isEqualTo(MacAlgorithm.HS256);
+	}
+
+	@Test
+	public void jwkSetUrlWhenProvidedThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder().jwkSetUrl("https://client.example.com/jwks").build();
+		assertThat(clientSettings.getJwkSetUrl()).isEqualTo("https://client.example.com/jwks");
+	}
+
+	@Test
+	public void x509CertificateSubjectDNWhenProvidedThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder()
+			.x509CertificateSubjectDN("CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US")
+			.build();
+		assertThat(clientSettings.getX509CertificateSubjectDN())
+			.isEqualTo("CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US");
+	}
+
+	@Test
+	public void settingWhenCustomThenSet() {
+		ClientSettings clientSettings = ClientSettings.builder()
+			.setting("name1", "value1")
+			.settings((settings) -> settings.put("name2", "value2"))
+			.build();
+		assertThat(clientSettings.getSettings()).hasSize(4);
+		assertThat(clientSettings.<String>getSetting("name1")).isEqualTo("value1");
+		assertThat(clientSettings.<String>getSetting("name2")).isEqualTo("value2");
+	}
+
+}

+ 171 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/settings/TokenSettingsTests.java

@@ -0,0 +1,171 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.settings;
+
+import java.time.Duration;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link TokenSettings}.
+ *
+ * @author Joe Grandja
+ */
+public class TokenSettingsTests {
+
+	@Test
+	public void buildWhenDefaultThenDefaultsAreSet() {
+		TokenSettings tokenSettings = TokenSettings.builder().build();
+		assertThat(tokenSettings.getSettings()).hasSize(8);
+		assertThat(tokenSettings.getAuthorizationCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5));
+		assertThat(tokenSettings.getAccessTokenTimeToLive()).isEqualTo(Duration.ofMinutes(5));
+		assertThat(tokenSettings.getAccessTokenFormat()).isEqualTo(OAuth2TokenFormat.SELF_CONTAINED);
+		assertThat(tokenSettings.getDeviceCodeTimeToLive()).isEqualTo(Duration.ofMinutes(5));
+		assertThat(tokenSettings.isReuseRefreshTokens()).isTrue();
+		assertThat(tokenSettings.getRefreshTokenTimeToLive()).isEqualTo(Duration.ofMinutes(60));
+		assertThat(tokenSettings.getIdTokenSignatureAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
+		assertThat(tokenSettings.isX509CertificateBoundAccessTokens()).isFalse();
+	}
+
+	@Test
+	public void authorizationCodeTimeToLiveWhenProvidedThenSet() {
+		Duration authorizationCodeTimeToLive = Duration.ofMinutes(10);
+		TokenSettings tokenSettings = TokenSettings.builder()
+			.authorizationCodeTimeToLive(authorizationCodeTimeToLive)
+			.build();
+		assertThat(tokenSettings.getAuthorizationCodeTimeToLive()).isEqualTo(authorizationCodeTimeToLive);
+	}
+
+	@Test
+	public void authorizationCodeTimeToLiveWhenNullOrZeroOrNegativeThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> TokenSettings.builder().authorizationCodeTimeToLive(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("authorizationCodeTimeToLive cannot be null");
+
+		assertThatThrownBy(() -> TokenSettings.builder().authorizationCodeTimeToLive(Duration.ZERO))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("authorizationCodeTimeToLive must be greater than Duration.ZERO");
+
+		assertThatThrownBy(() -> TokenSettings.builder().authorizationCodeTimeToLive(Duration.ofSeconds(-10)))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("authorizationCodeTimeToLive must be greater than Duration.ZERO");
+	}
+
+	@Test
+	public void accessTokenTimeToLiveWhenProvidedThenSet() {
+		Duration accessTokenTimeToLive = Duration.ofMinutes(10);
+		TokenSettings tokenSettings = TokenSettings.builder().accessTokenTimeToLive(accessTokenTimeToLive).build();
+		assertThat(tokenSettings.getAccessTokenTimeToLive()).isEqualTo(accessTokenTimeToLive);
+	}
+
+	@Test
+	public void accessTokenTimeToLiveWhenNullOrZeroOrNegativeThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> TokenSettings.builder().accessTokenTimeToLive(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("accessTokenTimeToLive cannot be null");
+
+		assertThatThrownBy(() -> TokenSettings.builder().accessTokenTimeToLive(Duration.ZERO))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("accessTokenTimeToLive must be greater than Duration.ZERO");
+
+		assertThatThrownBy(() -> TokenSettings.builder().accessTokenTimeToLive(Duration.ofSeconds(-10)))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("accessTokenTimeToLive must be greater than Duration.ZERO");
+	}
+
+	@Test
+	public void accessTokenFormatWhenProvidedThenSet() {
+		TokenSettings tokenSettings = TokenSettings.builder().accessTokenFormat(OAuth2TokenFormat.REFERENCE).build();
+		assertThat(tokenSettings.getAccessTokenFormat()).isEqualTo(OAuth2TokenFormat.REFERENCE);
+	}
+
+	@Test
+	public void accessTokenFormatWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> TokenSettings.builder().accessTokenFormat(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("accessTokenFormat cannot be null");
+	}
+
+	@Test
+	public void reuseRefreshTokensWhenFalseThenSet() {
+		TokenSettings tokenSettings = TokenSettings.builder().reuseRefreshTokens(false).build();
+		assertThat(tokenSettings.isReuseRefreshTokens()).isFalse();
+	}
+
+	@Test
+	public void refreshTokenTimeToLiveWhenProvidedThenSet() {
+		Duration refreshTokenTimeToLive = Duration.ofDays(10);
+		TokenSettings tokenSettings = TokenSettings.builder().refreshTokenTimeToLive(refreshTokenTimeToLive).build();
+		assertThat(tokenSettings.getRefreshTokenTimeToLive()).isEqualTo(refreshTokenTimeToLive);
+	}
+
+	@Test
+	public void refreshTokenTimeToLiveWhenNullOrZeroOrNegativeThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> TokenSettings.builder().refreshTokenTimeToLive(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("refreshTokenTimeToLive cannot be null");
+
+		assertThatThrownBy(() -> TokenSettings.builder().refreshTokenTimeToLive(Duration.ZERO))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("refreshTokenTimeToLive must be greater than Duration.ZERO");
+
+		assertThatThrownBy(() -> TokenSettings.builder().refreshTokenTimeToLive(Duration.ofSeconds(-10)))
+			.isInstanceOf(IllegalArgumentException.class)
+			.extracting(Throwable::getMessage)
+			.isEqualTo("refreshTokenTimeToLive must be greater than Duration.ZERO");
+	}
+
+	@Test
+	public void idTokenSignatureAlgorithmWhenProvidedThenSet() {
+		SignatureAlgorithm idTokenSignatureAlgorithm = SignatureAlgorithm.RS512;
+		TokenSettings tokenSettings = TokenSettings.builder()
+			.idTokenSignatureAlgorithm(idTokenSignatureAlgorithm)
+			.build();
+		assertThat(tokenSettings.getIdTokenSignatureAlgorithm()).isEqualTo(idTokenSignatureAlgorithm);
+	}
+
+	@Test
+	public void x509CertificateBoundAccessTokensWhenTrueThenSet() {
+		TokenSettings tokenSettings = TokenSettings.builder().x509CertificateBoundAccessTokens(true).build();
+		assertThat(tokenSettings.isX509CertificateBoundAccessTokens()).isTrue();
+	}
+
+	@Test
+	public void settingWhenCustomThenSet() {
+		TokenSettings tokenSettings = TokenSettings.builder()
+			.setting("name1", "value1")
+			.settings((settings) -> settings.put("name2", "value2"))
+			.build();
+		assertThat(tokenSettings.getSettings()).hasSize(10);
+		assertThat(tokenSettings.<String>getSetting("name1")).isEqualTo("value1");
+		assertThat(tokenSettings.<String>getSetting("name2")).isEqualTo("value2");
+	}
+
+}

+ 146 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/test/SpringTestContext.java

@@ -0,0 +1,146 @@
+/*
+ * Copyright 2020-2022 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.test;
+
+import java.io.Closeable;
+import java.util.ArrayList;
+import java.util.List;
+
+import jakarta.servlet.Filter;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.springframework.beans.factory.annotation.AutowiredAnnotationBeanPostProcessor;
+import org.springframework.mock.web.MockServletConfig;
+import org.springframework.mock.web.MockServletContext;
+import org.springframework.security.config.BeanIds;
+import org.springframework.test.context.web.GenericXmlWebContextLoader;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.RequestPostProcessor;
+import org.springframework.test.web.servlet.setup.ConfigurableMockMvcBuilder;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+import org.springframework.test.web.servlet.setup.MockMvcConfigurer;
+import org.springframework.web.context.ConfigurableWebApplicationContext;
+import org.springframework.web.context.WebApplicationContext;
+import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
+import org.springframework.web.context.support.XmlWebApplicationContext;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
+
+/**
+ * @author Rob Winch
+ */
+public class SpringTestContext implements Closeable {
+
+	private Object test;
+
+	private ConfigurableWebApplicationContext context;
+
+	private List<Filter> filters = new ArrayList<>();
+
+	public void setTest(Object test) {
+		this.test = test;
+	}
+
+	@Override
+	public void close() {
+		try {
+			this.context.close();
+		}
+		catch (Exception ex) {
+		}
+	}
+
+	public SpringTestContext context(ConfigurableWebApplicationContext context) {
+		this.context = context;
+		return this;
+	}
+
+	public SpringTestContext register(Class<?>... classes) {
+		AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext();
+		applicationContext.register(classes);
+		this.context = applicationContext;
+		return this;
+	}
+
+	public SpringTestContext testConfigLocations(String... configLocations) {
+		GenericXmlWebContextLoader loader = new GenericXmlWebContextLoader();
+		String[] locations = loader.processLocations(this.test.getClass(), configLocations);
+		return configLocations(locations);
+	}
+
+	public SpringTestContext configLocations(String... configLocations) {
+		XmlWebApplicationContext context = new XmlWebApplicationContext();
+		context.setConfigLocations(configLocations);
+		this.context = context;
+		return this;
+	}
+
+	public SpringTestContext mockMvcAfterSpringSecurityOk() {
+		return addFilter(new OncePerRequestFilter() {
+			@Override
+			protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+					FilterChain filterChain) {
+				response.setStatus(HttpServletResponse.SC_OK);
+			}
+		});
+	}
+
+	private SpringTestContext addFilter(Filter filter) {
+		this.filters.add(filter);
+		return this;
+	}
+
+	public ConfigurableWebApplicationContext getContext() {
+		if (!this.context.isRunning()) {
+			this.context.setServletContext(new MockServletContext());
+			this.context.setServletConfig(new MockServletConfig());
+			this.context.refresh();
+		}
+		return this.context;
+	}
+
+	public void autowire() {
+		this.context.setServletContext(new MockServletContext());
+		this.context.setServletConfig(new MockServletConfig());
+		this.context.refresh();
+
+		if (this.context.containsBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN)) {
+			MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.context)
+				.apply(springSecurity())
+				.apply(new AddFilter())
+				.build();
+			this.context.getBeanFactory().registerResolvableDependency(MockMvc.class, mockMvc);
+		}
+
+		AutowiredAnnotationBeanPostProcessor bpp = new AutowiredAnnotationBeanPostProcessor();
+		bpp.setBeanFactory(this.context.getBeanFactory());
+		bpp.processInjection(this.test);
+	}
+
+	public class AddFilter implements MockMvcConfigurer {
+
+		public RequestPostProcessor beforeMockMvcCreated(ConfigurableMockMvcBuilder<?> builder,
+				WebApplicationContext context) {
+			builder.addFilters(SpringTestContext.this.filters.toArray(new Filter[0]));
+			return null;
+		}
+
+	}
+
+}

+ 54 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/test/SpringTestContextExtension.java

@@ -0,0 +1,54 @@
+/*
+ * Copyright 2020-2022 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.test;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.List;
+
+import org.junit.jupiter.api.extension.AfterEachCallback;
+import org.junit.jupiter.api.extension.BeforeEachCallback;
+import org.junit.jupiter.api.extension.ExtensionContext;
+
+import org.springframework.security.test.context.TestSecurityContextHolder;
+
+public class SpringTestContextExtension implements BeforeEachCallback, AfterEachCallback {
+
+	@Override
+	public void beforeEach(ExtensionContext context) throws Exception {
+		Object testInstance = context.getRequiredTestInstance();
+		getContexts(testInstance).forEach((springTestContext) -> springTestContext.setTest(testInstance));
+	}
+
+	@Override
+	public void afterEach(ExtensionContext context) throws Exception {
+		TestSecurityContextHolder.clearContext();
+		Object testInstance = context.getRequiredTestInstance();
+		getContexts(testInstance).forEach(SpringTestContext::close);
+	}
+
+	private static List<SpringTestContext> getContexts(Object test) throws IllegalAccessException {
+		Field[] declaredFields = test.getClass().getDeclaredFields();
+		List<SpringTestContext> result = new ArrayList<>();
+		for (Field field : declaredFields) {
+			if (SpringTestContext.class.isAssignableFrom(field.getType())) {
+				result.add((SpringTestContext) field.get(test));
+			}
+		}
+		return result;
+	}
+
+}

+ 86 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/DelegatingOAuth2TokenGeneratorTests.java

@@ -0,0 +1,86 @@
+/*
+ * Copyright 2020-2022 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.token;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.OAuth2Token;
+
+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.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link DelegatingOAuth2TokenGenerator}.
+ *
+ * @author Joe Grandja
+ */
+public class DelegatingOAuth2TokenGeneratorTests {
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void constructorWhenTokenGeneratorsEmptyThenThrowIllegalArgumentException() {
+		OAuth2TokenGenerator<OAuth2Token>[] tokenGenerators = new OAuth2TokenGenerator[0];
+		assertThatThrownBy(() -> new DelegatingOAuth2TokenGenerator(tokenGenerators))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("tokenGenerators cannot be empty");
+	}
+
+	@Test
+	public void constructorWhenTokenGeneratorsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new DelegatingOAuth2TokenGenerator(null, null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("tokenGenerator cannot be null");
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void generateWhenTokenGeneratorSupportedThenReturnToken() {
+		OAuth2TokenGenerator<OAuth2Token> tokenGenerator1 = mock(OAuth2TokenGenerator.class);
+		OAuth2TokenGenerator<OAuth2Token> tokenGenerator2 = mock(OAuth2TokenGenerator.class);
+		OAuth2TokenGenerator<OAuth2Token> tokenGenerator3 = mock(OAuth2TokenGenerator.class);
+
+		OAuth2AccessToken accessToken = new OAuth2AccessToken(OAuth2AccessToken.TokenType.BEARER, "access-token",
+				Instant.now(), Instant.now().plusSeconds(300));
+		given(tokenGenerator3.generate(any())).willReturn(accessToken);
+
+		DelegatingOAuth2TokenGenerator delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(tokenGenerator1,
+				tokenGenerator2, tokenGenerator3);
+
+		OAuth2Token token = delegatingTokenGenerator.generate(DefaultOAuth2TokenContext.builder().build());
+		assertThat(token).isEqualTo(accessToken);
+	}
+
+	@Test
+	@SuppressWarnings("unchecked")
+	public void generateWhenTokenGeneratorNotSupportedThenReturnNull() {
+		OAuth2TokenGenerator<OAuth2Token> tokenGenerator1 = mock(OAuth2TokenGenerator.class);
+		OAuth2TokenGenerator<OAuth2Token> tokenGenerator2 = mock(OAuth2TokenGenerator.class);
+		OAuth2TokenGenerator<OAuth2Token> tokenGenerator3 = mock(OAuth2TokenGenerator.class);
+
+		DelegatingOAuth2TokenGenerator delegatingTokenGenerator = new DelegatingOAuth2TokenGenerator(tokenGenerator1,
+				tokenGenerator2, tokenGenerator3);
+
+		OAuth2Token token = delegatingTokenGenerator.generate(DefaultOAuth2TokenContext.builder().build());
+		assertThat(token).isNull();
+	}
+
+}

+ 113 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtEncodingContextTests.java

@@ -0,0 +1,113 @@
+/*
+ * Copyright 2020-2022 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.token;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.authentication.TestingAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.TestJwsHeaders;
+import org.springframework.security.oauth2.jwt.TestJwtClaimsSets;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link JwtEncodingContext}.
+ *
+ * @author Joe Grandja
+ */
+public class JwtEncodingContextTests {
+
+	@Test
+	public void withWhenJwsHeaderNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> JwtEncodingContext.with(null, TestJwtClaimsSets.jwtClaimsSet()))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("jwsHeaderBuilder cannot be null");
+	}
+
+	@Test
+	public void withWhenClaimsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> JwtEncodingContext.with(TestJwsHeaders.jwsHeader(), null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("claimsBuilder cannot be null");
+	}
+
+	@Test
+	public void setWhenValueNullThenThrowIllegalArgumentException() {
+		JwtEncodingContext.Builder builder = JwtEncodingContext.with(TestJwsHeaders.jwsHeader(),
+				TestJwtClaimsSets.jwtClaimsSet());
+		assertThatThrownBy(() -> builder.registeredClient(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.principal(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.authorization(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.tokenType(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.authorizationGrantType(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.authorizationGrant(null)).isInstanceOf(IllegalArgumentException.class);
+		assertThatThrownBy(() -> builder.put(null, "")).isInstanceOf(IllegalArgumentException.class);
+	}
+
+	@Test
+	public void buildWhenAllValuesProvidedThenAllValuesAreSet() {
+		JwsHeader.Builder headers = TestJwsHeaders.jwsHeader();
+		JwtClaimsSet.Builder claims = TestJwtClaimsSets.jwtClaimsSet();
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		TestingAuthenticationToken principal = new TestingAuthenticationToken("principal", "password");
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization().build();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authorizationGrant = new OAuth2AuthorizationCodeAuthenticationToken(
+				"code", clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		JwtEncodingContext context = JwtEncodingContext.with(headers, claims)
+			.registeredClient(registeredClient)
+			.principal(principal)
+			.authorization(authorization)
+			.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+			.authorizationGrant(authorizationGrant)
+			.put("custom-key-1", "custom-value-1")
+			.context((ctx) -> ctx.put("custom-key-2", "custom-value-2"))
+			.build();
+
+		assertThat(context.getJwsHeader()).isEqualTo(headers);
+		assertThat(context.getClaims()).isEqualTo(claims);
+		assertThat(context.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(context.<Authentication>getPrincipal()).isEqualTo(principal);
+		assertThat(context.getAuthorization()).isEqualTo(authorization);
+		assertThat(context.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(context.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(context.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authorizationGrant);
+		assertThat(context.<String>get("custom-key-1")).isEqualTo("custom-value-1");
+		assertThat(context.<String>get("custom-key-2")).isEqualTo("custom-value-2");
+	}
+
+}

+ 360 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/JwtGeneratorTests.java

@@ -0,0 +1,360 @@
+/*
+ * 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.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;
+
+import org.junit.jupiter.api.BeforeEach;
+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.OAuth2RefreshToken;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+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.OidcScopes;
+import org.springframework.security.oauth2.core.oidc.endpoint.OidcParameterNames;
+import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
+import org.springframework.security.oauth2.jwt.JwsHeader;
+import org.springframework.security.oauth2.jwt.JwtClaimsSet;
+import org.springframework.security.oauth2.jwt.JwtEncoder;
+import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2ClientAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2RefreshTokenAuthenticationToken;
+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.TestAuthorizationServerContext;
+import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings;
+import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link JwtGenerator}.
+ *
+ * @author Joe Grandja
+ */
+public class JwtGeneratorTests {
+
+	private static final OAuth2TokenType ID_TOKEN_TOKEN_TYPE = new OAuth2TokenType(OidcParameterNames.ID_TOKEN);
+
+	private JwtEncoder jwtEncoder;
+
+	private OAuth2TokenCustomizer<JwtEncodingContext> jwtCustomizer;
+
+	private JwtGenerator jwtGenerator;
+
+	private TestAuthorizationServerContext authorizationServerContext;
+
+	@BeforeEach
+	public void setUp() {
+		this.jwtEncoder = mock(JwtEncoder.class);
+		this.jwtCustomizer = mock(OAuth2TokenCustomizer.class);
+		this.jwtGenerator = new JwtGenerator(this.jwtEncoder);
+		this.jwtGenerator.setJwtCustomizer(this.jwtCustomizer);
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://provider.com")
+			.build();
+		this.authorizationServerContext = new TestAuthorizationServerContext(authorizationServerSettings, null);
+	}
+
+	@Test
+	public void constructorWhenJwtEncoderNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> new JwtGenerator(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("jwtEncoder cannot be null");
+	}
+
+	@Test
+	public void setJwtCustomizerWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.jwtGenerator.setJwtCustomizer(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("jwtCustomizer cannot be null");
+	}
+
+	@Test
+	public void generateWhenUnsupportedTokenTypeThenReturnNull() {
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.tokenType(new OAuth2TokenType("unsupported_token_type"))
+				.build();
+		// @formatter:on
+
+		assertThat(this.jwtGenerator.generate(tokenContext)).isNull();
+	}
+
+	@Test
+	public void generateWhenUnsupportedTokenFormatThenReturnNull() {
+		// @formatter:off
+		TokenSettings tokenSettings = TokenSettings.builder()
+				.accessTokenFormat(new OAuth2TokenFormat("unsupported_token_format"))
+				.build();
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(tokenSettings)
+				.build();
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.build();
+		// @formatter:on
+
+		assertThat(this.jwtGenerator.generate(tokenContext)).isNull();
+	}
+
+	@Test
+	public void generateWhenAccessTokenTypeThenReturnJwt() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				"code", clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.principal(authorization.getAttribute(Principal.class.getName()))
+				.authorizationServerContext(this.authorizationServerContext)
+				.authorization(authorization)
+				.authorizedScopes(authorization.getAuthorizedScopes())
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+				.authorizationGrant(authentication)
+				.build();
+		// @formatter:on
+
+		assertGeneratedTokenType(tokenContext);
+	}
+
+	@Test
+	public void generateWhenIdTokenTypeAndAuthorizationCodeGrantThenReturnJwt() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+			.scope(OidcScopes.OPENID)
+			.tokenSettings(TokenSettings.builder().idTokenSignatureAlgorithm(SignatureAlgorithm.ES256).build())
+			.build();
+		Map<String, Object> authenticationRequestAdditionalParameters = new HashMap<>();
+		authenticationRequestAdditionalParameters.put(OidcParameterNames.NONCE, "nonce");
+		OAuth2Authorization authorization = TestOAuth2Authorizations
+			.authorization(registeredClient, authenticationRequestAdditionalParameters)
+			.build();
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		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(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
+
+		assertGeneratedTokenType(tokenContext);
+	}
+
+	// gh-1224
+	@Test
+	public void generateWhenIdTokenTypeAndRefreshTokenGrantThenReturnJwt() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.subject("subject")
+			.issuedAt(Instant.now())
+			.expiresAt(Instant.now().plusSeconds(60))
+			.claim("sid", "sessionId-1234")
+			.claim(IdTokenClaimNames.AUTH_TIME, Date.from(Instant.now()))
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(idToken)
+			.build();
+
+		OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				refreshToken.getTokenValue(), clientPrincipal, null, null);
+
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.principal(principal)
+				.authorizationServerContext(this.authorizationServerContext)
+				.authorization(authorization)
+				.authorizedScopes(authorization.getAuthorizedScopes())
+				.tokenType(ID_TOKEN_TOKEN_TYPE)
+				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+				.authorizationGrant(authentication)
+				.build();
+		// @formatter:on
+
+		assertGeneratedTokenType(tokenContext);
+	}
+
+	// gh-1283
+	@Test
+	public void generateWhenIdTokenTypeWithoutSidAndRefreshTokenGrantThenReturnJwt() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().scope(OidcScopes.OPENID).build();
+		OidcIdToken idToken = OidcIdToken.withTokenValue("id-token")
+			.issuer("https://provider.com")
+			.subject("subject")
+			.issuedAt(Instant.now())
+			.expiresAt(Instant.now().plusSeconds(60))
+			.build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient)
+			.token(idToken)
+			.build();
+
+		OAuth2RefreshToken refreshToken = authorization.getRefreshToken().getToken();
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+
+		OAuth2RefreshTokenAuthenticationToken authentication = new OAuth2RefreshTokenAuthenticationToken(
+				refreshToken.getTokenValue(), clientPrincipal, null, null);
+
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.principal(principal)
+				.authorizationServerContext(this.authorizationServerContext)
+				.authorization(authorization)
+				.authorizedScopes(authorization.getAuthorizedScopes())
+				.tokenType(ID_TOKEN_TOKEN_TYPE)
+				.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
+				.authorizationGrant(authentication)
+				.build();
+		// @formatter:on
+
+		assertGeneratedTokenType(tokenContext);
+	}
+
+	private void assertGeneratedTokenType(OAuth2TokenContext tokenContext) {
+		this.jwtGenerator.generate(tokenContext);
+
+		ArgumentCaptor<JwtEncodingContext> jwtEncodingContextCaptor = ArgumentCaptor.forClass(JwtEncodingContext.class);
+		verify(this.jwtCustomizer).customize(jwtEncodingContextCaptor.capture());
+
+		JwtEncodingContext jwtEncodingContext = jwtEncodingContextCaptor.getValue();
+		assertThat(jwtEncodingContext.getJwsHeader()).isNotNull();
+		assertThat(jwtEncodingContext.getClaims()).isNotNull();
+		assertThat(jwtEncodingContext.getRegisteredClient()).isEqualTo(tokenContext.getRegisteredClient());
+		assertThat(jwtEncodingContext.<Authentication>getPrincipal()).isEqualTo(tokenContext.getPrincipal());
+		assertThat(jwtEncodingContext.getAuthorization()).isEqualTo(tokenContext.getAuthorization());
+		assertThat(jwtEncodingContext.getAuthorizedScopes()).isEqualTo(tokenContext.getAuthorizedScopes());
+		assertThat(jwtEncodingContext.getTokenType()).isEqualTo(tokenContext.getTokenType());
+		assertThat(jwtEncodingContext.getAuthorizationGrantType()).isEqualTo(tokenContext.getAuthorizationGrantType());
+		assertThat(jwtEncodingContext.<Authentication>getAuthorizationGrant())
+			.isEqualTo(tokenContext.getAuthorizationGrant());
+
+		ArgumentCaptor<JwtEncoderParameters> jwtEncoderParametersCaptor = ArgumentCaptor
+			.forClass(JwtEncoderParameters.class);
+		verify(this.jwtEncoder).encode(jwtEncoderParametersCaptor.capture());
+
+		JwsHeader jwsHeader = jwtEncoderParametersCaptor.getValue().getJwsHeader();
+		if (OidcParameterNames.ID_TOKEN.equals(tokenContext.getTokenType().getValue())) {
+			assertThat(jwsHeader.getAlgorithm())
+				.isEqualTo(tokenContext.getRegisteredClient().getTokenSettings().getIdTokenSignatureAlgorithm());
+		}
+		else {
+			assertThat(jwsHeader.getAlgorithm()).isEqualTo(SignatureAlgorithm.RS256);
+		}
+
+		JwtClaimsSet jwtClaimsSet = jwtEncoderParametersCaptor.getValue().getClaims();
+		assertThat(jwtClaimsSet.getIssuer().toExternalForm())
+			.isEqualTo(tokenContext.getAuthorizationServerContext().getIssuer());
+		assertThat(jwtClaimsSet.getSubject()).isEqualTo(tokenContext.getAuthorization().getPrincipalName());
+		assertThat(jwtClaimsSet.getAudience()).containsExactly(tokenContext.getRegisteredClient().getClientId());
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt;
+		if (tokenContext.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
+			expiresAt = issuedAt.plus(tokenContext.getRegisteredClient().getTokenSettings().getAccessTokenTimeToLive());
+		}
+		else {
+			expiresAt = issuedAt.plus(30, ChronoUnit.MINUTES);
+		}
+		assertThat(jwtClaimsSet.getIssuedAt()).isBetween(issuedAt.minusSeconds(1), issuedAt.plusSeconds(1));
+		assertThat(jwtClaimsSet.getExpiresAt()).isBetween(expiresAt.minusSeconds(1), expiresAt.plusSeconds(1));
+		assertThat(jwtClaimsSet.getId()).isNotNull();
+
+		if (tokenContext.getTokenType().equals(OAuth2TokenType.ACCESS_TOKEN)) {
+			assertThat(jwtClaimsSet.getNotBefore()).isBetween(issuedAt.minusSeconds(1), issuedAt.plusSeconds(1));
+
+			Set<String> scopes = jwtClaimsSet.getClaim(OAuth2ParameterNames.SCOPE);
+			assertThat(scopes).isEqualTo(tokenContext.getAuthorizedScopes());
+		}
+		else {
+			assertThat(jwtClaimsSet.<String>getClaim(IdTokenClaimNames.AZP))
+				.isEqualTo(tokenContext.getRegisteredClient().getClientId());
+			if (tokenContext.getAuthorizationGrantType().equals(AuthorizationGrantType.AUTHORIZATION_CODE)) {
+				OAuth2AuthorizationRequest authorizationRequest = tokenContext.getAuthorization()
+					.getAttribute(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());
+			}
+			else if (tokenContext.getAuthorizationGrantType().equals(AuthorizationGrantType.REFRESH_TOKEN)) {
+				OidcIdToken currentIdToken = tokenContext.getAuthorization().getToken(OidcIdToken.class).getToken();
+				assertThat(jwtClaimsSet.<String>getClaim("sid")).isEqualTo(currentIdToken.getClaim("sid"));
+				assertThat(jwtClaimsSet.<Date>getClaim(IdTokenClaimNames.AUTH_TIME))
+					.isEqualTo(currentIdToken.<Date>getClaim(IdTokenClaimNames.AUTH_TIME));
+			}
+		}
+	}
+
+}

+ 197 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2AccessTokenGeneratorTests.java

@@ -0,0 +1,197 @@
+/*
+ * Copyright 2020-2022 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.token;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Set;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClaimAccessor;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.OAuth2AccessToken;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken;
+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;
+import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+/**
+ * Tests for {@link OAuth2AccessTokenGenerator}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2AccessTokenGeneratorTests {
+
+	private OAuth2TokenCustomizer<OAuth2TokenClaimsContext> accessTokenCustomizer;
+
+	private OAuth2AccessTokenGenerator accessTokenGenerator;
+
+	private AuthorizationServerContext authorizationServerContext;
+
+	@BeforeEach
+	public void setUp() {
+		this.accessTokenCustomizer = mock(OAuth2TokenCustomizer.class);
+		this.accessTokenGenerator = new OAuth2AccessTokenGenerator();
+		this.accessTokenGenerator.setAccessTokenCustomizer(this.accessTokenCustomizer);
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer("https://provider.com")
+			.build();
+		this.authorizationServerContext = new TestAuthorizationServerContext(authorizationServerSettings, null);
+	}
+
+	@Test
+	public void setAccessTokenCustomizerWhenNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> this.accessTokenGenerator.setAccessTokenCustomizer(null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("accessTokenCustomizer cannot be null");
+	}
+
+	@Test
+	public void generateWhenUnsupportedTokenTypeThenReturnNull() {
+		// @formatter:off
+		TokenSettings tokenSettings = TokenSettings.builder()
+				.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
+				.build();
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(tokenSettings)
+				.build();
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.tokenType(new OAuth2TokenType("unsupported_token_type"))
+				.build();
+		// @formatter:on
+
+		assertThat(this.accessTokenGenerator.generate(tokenContext)).isNull();
+	}
+
+	@Test
+	public void generateWhenUnsupportedTokenFormatThenReturnNull() {
+		// @formatter:off
+		TokenSettings tokenSettings = TokenSettings.builder()
+				.accessTokenFormat(new OAuth2TokenFormat("unsupported_token_format"))
+				.build();
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(tokenSettings)
+				.build();
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.build();
+		// @formatter:on
+
+		assertThat(this.accessTokenGenerator.generate(tokenContext)).isNull();
+	}
+
+	@Test
+	public void generateWhenReferenceAccessTokenTypeThenReturnAccessToken() {
+		// @formatter:off
+		TokenSettings tokenSettings = TokenSettings.builder()
+				.accessTokenFormat(OAuth2TokenFormat.REFERENCE)
+				.build();
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient()
+				.tokenSettings(tokenSettings)
+				.build();
+		// @formatter:on
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authentication = new OAuth2AuthorizationCodeAuthenticationToken(
+				"code", clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.principal(principal)
+				.authorizationServerContext(this.authorizationServerContext)
+				.authorization(authorization)
+				.authorizedScopes(authorization.getAuthorizedScopes())
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+				.authorizationGrant(authentication)
+				.build();
+		// @formatter:on
+
+		OAuth2AccessToken accessToken = this.accessTokenGenerator.generate(tokenContext);
+		assertThat(accessToken).isNotNull();
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt
+			.plus(tokenContext.getRegisteredClient().getTokenSettings().getAccessTokenTimeToLive());
+		assertThat(accessToken.getIssuedAt()).isBetween(issuedAt.minusSeconds(1), issuedAt.plusSeconds(1));
+		assertThat(accessToken.getExpiresAt()).isBetween(expiresAt.minusSeconds(1), expiresAt.plusSeconds(1));
+		assertThat(accessToken.getScopes()).isEqualTo(tokenContext.getAuthorizedScopes());
+
+		assertThat(accessToken).isInstanceOf(ClaimAccessor.class);
+		OAuth2TokenClaimAccessor accessTokenClaims = ((ClaimAccessor) accessToken)::getClaims;
+		assertThat(accessTokenClaims.getClaims()).isNotEmpty();
+
+		assertThat(accessTokenClaims.getIssuer().toExternalForm())
+			.isEqualTo(tokenContext.getAuthorizationServerContext().getIssuer());
+		assertThat(accessTokenClaims.getSubject()).isEqualTo(tokenContext.getPrincipal().getName());
+		assertThat(accessTokenClaims.getAudience())
+			.isEqualTo(Collections.singletonList(tokenContext.getRegisteredClient().getClientId()));
+		assertThat(accessTokenClaims.getIssuedAt()).isBetween(issuedAt.minusSeconds(1), issuedAt.plusSeconds(1));
+		assertThat(accessTokenClaims.getExpiresAt()).isBetween(expiresAt.minusSeconds(1), expiresAt.plusSeconds(1));
+		assertThat(accessTokenClaims.getNotBefore()).isBetween(issuedAt.minusSeconds(1), issuedAt.plusSeconds(1));
+		assertThat(accessTokenClaims.getId()).isNotNull();
+
+		Set<String> scopes = accessTokenClaims.getClaim(OAuth2ParameterNames.SCOPE);
+		assertThat(scopes).isEqualTo(tokenContext.getAuthorizedScopes());
+
+		ArgumentCaptor<OAuth2TokenClaimsContext> tokenClaimsContextCaptor = ArgumentCaptor
+			.forClass(OAuth2TokenClaimsContext.class);
+		verify(this.accessTokenCustomizer).customize(tokenClaimsContextCaptor.capture());
+
+		OAuth2TokenClaimsContext tokenClaimsContext = tokenClaimsContextCaptor.getValue();
+		assertThat(tokenClaimsContext.getClaims()).isNotNull();
+		assertThat(tokenClaimsContext.getRegisteredClient()).isEqualTo(tokenContext.getRegisteredClient());
+		assertThat(tokenClaimsContext.<Authentication>getPrincipal()).isEqualTo(tokenContext.getPrincipal());
+		assertThat(tokenClaimsContext.getAuthorizationServerContext())
+			.isEqualTo(tokenContext.getAuthorizationServerContext());
+		assertThat(tokenClaimsContext.getAuthorization()).isEqualTo(tokenContext.getAuthorization());
+		assertThat(tokenClaimsContext.getAuthorizedScopes()).isEqualTo(tokenContext.getAuthorizedScopes());
+		assertThat(tokenClaimsContext.getTokenType()).isEqualTo(tokenContext.getTokenType());
+		assertThat(tokenClaimsContext.getAuthorizationGrantType()).isEqualTo(tokenContext.getAuthorizationGrantType());
+		assertThat(tokenClaimsContext.<Authentication>getAuthorizationGrant())
+			.isEqualTo(tokenContext.getAuthorizationGrant());
+	}
+
+}

+ 70 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2RefreshTokenGeneratorTests.java

@@ -0,0 +1,70 @@
+/*
+ * Copyright 2020-2022 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.token;
+
+import java.time.Instant;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.oauth2.core.OAuth2RefreshToken;
+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.TestRegisteredClients;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for {@link OAuth2RefreshTokenGenerator}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2RefreshTokenGeneratorTests {
+
+	private final OAuth2RefreshTokenGenerator tokenGenerator = new OAuth2RefreshTokenGenerator();
+
+	@Test
+	public void generateWhenUnsupportedTokenTypeThenReturnNull() {
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.build();
+		// @formatter:on
+
+		assertThat(this.tokenGenerator.generate(tokenContext)).isNull();
+	}
+
+	@Test
+	public void generateWhenRefreshTokenTypeThenReturnRefreshToken() {
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+
+		// @formatter:off
+		OAuth2TokenContext tokenContext = DefaultOAuth2TokenContext.builder()
+				.registeredClient(registeredClient)
+				.tokenType(OAuth2TokenType.REFRESH_TOKEN)
+				.build();
+		// @formatter:on
+
+		OAuth2RefreshToken refreshToken = this.tokenGenerator.generate(tokenContext);
+		assertThat(refreshToken).isNotNull();
+
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt
+			.plus(tokenContext.getRegisteredClient().getTokenSettings().getRefreshTokenTimeToLive());
+		assertThat(refreshToken.getIssuedAt()).isBetween(issuedAt.minusSeconds(1), issuedAt.plusSeconds(1));
+		assertThat(refreshToken.getExpiresAt()).isBetween(expiresAt.minusSeconds(1), expiresAt.plusSeconds(1));
+	}
+
+}

+ 116 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenClaimsContextTests.java

@@ -0,0 +1,116 @@
+/*
+ * Copyright 2020-2022 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.token;
+
+import java.security.Principal;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import org.springframework.security.core.Authentication;
+import org.springframework.security.oauth2.core.AuthorizationGrantType;
+import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
+import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest;
+import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
+import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
+import org.springframework.security.oauth2.server.authorization.TestOAuth2Authorizations;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationToken;
+import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationGrantAuthenticationToken;
+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 static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2TokenClaimsContext}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2TokenClaimsContextTests {
+
+	@Test
+	public void withWhenClaimsNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2TokenClaimsContext.with(null)).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("claimsBuilder cannot be null");
+	}
+
+	@Test
+	public void buildWhenAllValuesProvidedThenAllValuesAreSet() {
+		String issuer = "https://provider.com";
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+
+		// @formatter:off
+		OAuth2TokenClaimsSet.Builder claims = OAuth2TokenClaimsSet.builder()
+				.issuer(issuer)
+				.subject("subject")
+				.audience(Collections.singletonList("client-1"))
+				.issuedAt(issuedAt)
+				.notBefore(issuedAt)
+				.expiresAt(expiresAt)
+				.id("id");
+		// @formatter:on
+
+		RegisteredClient registeredClient = TestRegisteredClients.registeredClient().build();
+		OAuth2Authorization authorization = TestOAuth2Authorizations.authorization(registeredClient).build();
+		Authentication principal = authorization.getAttribute(Principal.class.getName());
+		AuthorizationServerSettings authorizationServerSettings = AuthorizationServerSettings.builder()
+			.issuer(issuer)
+			.build();
+		AuthorizationServerContext authorizationServerContext = new TestAuthorizationServerContext(
+				authorizationServerSettings, null);
+		OAuth2ClientAuthenticationToken clientPrincipal = new OAuth2ClientAuthenticationToken(registeredClient,
+				ClientAuthenticationMethod.CLIENT_SECRET_BASIC, registeredClient.getClientSecret());
+		OAuth2AuthorizationRequest authorizationRequest = authorization
+			.getAttribute(OAuth2AuthorizationRequest.class.getName());
+		OAuth2AuthorizationCodeAuthenticationToken authorizationGrant = new OAuth2AuthorizationCodeAuthenticationToken(
+				"code", clientPrincipal, authorizationRequest.getRedirectUri(), null);
+
+		// @formatter:off
+		OAuth2TokenClaimsContext context = OAuth2TokenClaimsContext.with(claims)
+				.registeredClient(registeredClient)
+				.principal(principal)
+				.authorizationServerContext(authorizationServerContext)
+				.authorization(authorization)
+				.tokenType(OAuth2TokenType.ACCESS_TOKEN)
+				.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+				.authorizationGrant(authorizationGrant)
+				.put("custom-key-1", "custom-value-1")
+				.context((ctx) -> ctx.put("custom-key-2", "custom-value-2"))
+				.build();
+		// @formatter:on
+
+		assertThat(context.getClaims()).isEqualTo(claims);
+		assertThat(context.getRegisteredClient()).isEqualTo(registeredClient);
+		assertThat(context.<Authentication>getPrincipal()).isEqualTo(principal);
+		assertThat(context.getAuthorizationServerContext()).isEqualTo(authorizationServerContext);
+		assertThat(context.getAuthorization()).isEqualTo(authorization);
+		assertThat(context.getTokenType()).isEqualTo(OAuth2TokenType.ACCESS_TOKEN);
+		assertThat(context.getAuthorizationGrantType()).isEqualTo(AuthorizationGrantType.AUTHORIZATION_CODE);
+		assertThat(context.<OAuth2AuthorizationGrantAuthenticationToken>getAuthorizationGrant())
+			.isEqualTo(authorizationGrant);
+		assertThat(context.<String>get("custom-key-1")).isEqualTo("custom-value-1");
+		assertThat(context.<String>get("custom-key-2")).isEqualTo("custom-value-2");
+	}
+
+}

+ 96 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/token/OAuth2TokenClaimsSetTests.java

@@ -0,0 +1,96 @@
+/*
+ * Copyright 2020-2022 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.token;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Tests for {@link OAuth2TokenClaimsSet}.
+ *
+ * @author Joe Grandja
+ */
+public class OAuth2TokenClaimsSetTests {
+
+	@Test
+	public void buildWhenClaimsEmptyThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2TokenClaimsSet.builder().build()).isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("claims cannot be empty");
+	}
+
+	@Test
+	public void buildWhenAllClaimsProvidedThenAllClaimsAreSet() {
+		Instant issuedAt = Instant.now();
+		Instant expiresAt = issuedAt.plus(1, ChronoUnit.HOURS);
+		String customClaimName = "custom-claim-name";
+		String customClaimValue = "custom-claim-value";
+
+		// @formatter:off
+		OAuth2TokenClaimsSet expectedClaimsSet = OAuth2TokenClaimsSet.builder()
+				.issuer("https://provider.com")
+				.subject("subject")
+				.audience(Collections.singletonList("client-1"))
+				.issuedAt(issuedAt)
+				.notBefore(issuedAt)
+				.expiresAt(expiresAt)
+				.id("id")
+				.claims((claims) -> claims.put(customClaimName, customClaimValue))
+				.build();
+
+		OAuth2TokenClaimsSet claimsSet = OAuth2TokenClaimsSet.builder()
+				.issuer(expectedClaimsSet.getIssuer().toExternalForm())
+				.subject(expectedClaimsSet.getSubject())
+				.audience(expectedClaimsSet.getAudience())
+				.issuedAt(expectedClaimsSet.getIssuedAt())
+				.notBefore(expectedClaimsSet.getNotBefore())
+				.expiresAt(expectedClaimsSet.getExpiresAt())
+				.id(expectedClaimsSet.getId())
+				.claims((claims) -> claims.put(customClaimName, expectedClaimsSet.getClaim(customClaimName)))
+				.build();
+		// @formatter:on
+
+		assertThat(claimsSet.getIssuer()).isEqualTo(expectedClaimsSet.getIssuer());
+		assertThat(claimsSet.getSubject()).isEqualTo(expectedClaimsSet.getSubject());
+		assertThat(claimsSet.getAudience()).isEqualTo(expectedClaimsSet.getAudience());
+		assertThat(claimsSet.getIssuedAt()).isEqualTo(expectedClaimsSet.getIssuedAt());
+		assertThat(claimsSet.getNotBefore()).isEqualTo(expectedClaimsSet.getNotBefore());
+		assertThat(claimsSet.getExpiresAt()).isEqualTo(expectedClaimsSet.getExpiresAt());
+		assertThat(claimsSet.getId()).isEqualTo(expectedClaimsSet.getId());
+		assertThat(claimsSet.<String>getClaim(customClaimName)).isEqualTo(expectedClaimsSet.getClaim(customClaimName));
+		assertThat(claimsSet.getClaims()).isEqualTo(expectedClaimsSet.getClaims());
+	}
+
+	@Test
+	public void claimWhenNameNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2TokenClaimsSet.builder().claim(null, "value"))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("name cannot be empty");
+	}
+
+	@Test
+	public void claimWhenValueNullThenThrowIllegalArgumentException() {
+		assertThatThrownBy(() -> OAuth2TokenClaimsSet.builder().claim("name", null))
+			.isInstanceOf(IllegalArgumentException.class)
+			.hasMessage("value cannot be null");
+	}
+
+}

+ 74 - 0
oauth2/oauth2-authorization-server/src/test/java/org/springframework/security/oauth2/server/authorization/util/TestX509Certificates.java

@@ -0,0 +1,74 @@
+/*
+ * Copyright 2020-2024 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.springframework.security.oauth2.server.authorization.util;
+
+import java.security.KeyPair;
+import java.security.cert.X509Certificate;
+
+/**
+ * @author Joe Grandja
+ */
+public final class TestX509Certificates {
+
+	public static final X509Certificate[] DEMO_CLIENT_PKI_CERTIFICATE;
+	static {
+		try {
+			// Generate the Root certificate (Trust Anchor or most-trusted CA)
+			KeyPair rootKeyPair = X509CertificateUtils.generateRSAKeyPair();
+			String distinguishedName = "CN=spring-samples-trusted-ca, OU=Spring Samples, O=Spring, C=US";
+			X509Certificate rootCertificate = X509CertificateUtils.createTrustAnchorCertificate(rootKeyPair,
+					distinguishedName);
+
+			// Generate the CA (intermediary) certificate
+			KeyPair caKeyPair = X509CertificateUtils.generateRSAKeyPair();
+			distinguishedName = "CN=spring-samples-ca, OU=Spring Samples, O=Spring, C=US";
+			X509Certificate caCertificate = X509CertificateUtils.createCACertificate(rootCertificate,
+					rootKeyPair.getPrivate(), caKeyPair.getPublic(), distinguishedName);
+
+			// Generate certificate for demo-client-sample
+			KeyPair demoClientKeyPair = X509CertificateUtils.generateRSAKeyPair();
+			distinguishedName = "CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US";
+			X509Certificate demoClientCertificate = X509CertificateUtils.createEndEntityCertificate(caCertificate,
+					caKeyPair.getPrivate(), demoClientKeyPair.getPublic(), distinguishedName);
+
+			DEMO_CLIENT_PKI_CERTIFICATE = new X509Certificate[] { demoClientCertificate, caCertificate,
+					rootCertificate };
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	public static final X509Certificate[] DEMO_CLIENT_SELF_SIGNED_CERTIFICATE;
+	static {
+		try {
+			// Generate self-signed certificate for demo-client-sample
+			KeyPair keyPair = X509CertificateUtils.generateRSAKeyPair();
+			String distinguishedName = "CN=demo-client-sample, OU=Spring Samples, O=Spring, C=US";
+			X509Certificate demoClientSelfSignedCertificate = X509CertificateUtils.createTrustAnchorCertificate(keyPair,
+					distinguishedName);
+
+			DEMO_CLIENT_SELF_SIGNED_CERTIFICATE = new X509Certificate[] { demoClientSelfSignedCertificate };
+		}
+		catch (Exception ex) {
+			throw new IllegalStateException(ex);
+		}
+	}
+
+	private TestX509Certificates() {
+	}
+
+}

Някои файлове не бяха показани, защото твърде много файлове са промени