Spring Security Authorization Server OAuth 2.1 公共客户端 PKCE

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.0.1</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>cn.com.xuxiaowei</groupId>
    <artifactId>spring-boot-oauth2-pkce</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>spring-boot-oauth2-pkce</name>
    <description>Spring Security Authorization Server OAuth 2.1 公共客户端 PKCE</description>
    <url/>
    <licenses>
        <license/>
    </licenses>
    <developers>
        <developer/>
    </developers>
    <scm>
        <connection/>
        <developerConnection/>
        <tag/>
        <url/>
    </scm>
    <properties>
        <java.version>17</java.version>
        <spring-javaformat-maven-plugin.version>0.0.47</spring-javaformat-maven-plugin.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-authorization-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc</artifactId>
        </dependency>

        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-authorization-server-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-oauth2-resource-server-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webmvc-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </path>
                        <path>
                            <groupId>org.springframework.boot</groupId>
                            <artifactId>spring-boot-configuration-processor</artifactId>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>io.spring.javaformat</groupId>
                <artifactId>spring-javaformat-maven-plugin</artifactId>
                <version>${spring-javaformat-maven-plugin.version}</version>
                <executions>
                    <execution>
                        <phase>validate</phase>
                        <inherited>true</inherited>
                        <goals>
                            <goal>validate</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>

</project>

AuthorizationServerConfig.java

package cn.com.xuxiaowei.config;

import cn.com.xuxiaowei.properties.SecurityProperties;
import cn.com.xuxiaowei.util.RSAUtils;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.RSAKey;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.jwk.source.JWKSource;
import com.nimbusds.jose.proc.SecurityContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.authentication.OAuth2AuthorizationCodeAuthenticationProvider;
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.settings.AuthorizationServerSettings;
import org.springframework.security.oauth2.server.authorization.web.authentication.OAuth2AuthorizationCodeAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;

import java.security.KeyPair;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.security.spec.InvalidKeySpecException;

/**
 * @author xuxiaowei
 */
@Slf4j
@Configuration(proxyBeanMethods = false)
public class AuthorizationServerConfig {

	/**
	 * <code>OAuth2TokenEndpointConfigurer#createDefaultAuthenticationConverters()</code>
	 * <code>OAuth2TokenEndpointConfigurer#createDefaultAuthenticationProviders(HttpSecurity)</code>
	 *
	 * @see OAuth2AuthorizationCodeAuthenticationConverter
	 * @see OAuth2AuthorizationCodeAuthenticationProvider
	 */
	@Bean
	@Order(Ordered.HIGHEST_PRECEDENCE)
	public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) {

		OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer();
		RequestMatcher endpointsMatcher = authorizationServerConfigurer.getEndpointsMatcher();

		authorizationServerConfigurer.oidc(Customizer.withDefaults());

		http.securityMatcher(endpointsMatcher)
			.authorizeHttpRequests((authorize) -> authorize.anyRequest().authenticated())
			.csrf((csrf) -> csrf.ignoringRequestMatchers(endpointsMatcher))
			.exceptionHandling((exceptions) -> exceptions.defaultAuthenticationEntryPointFor(
					new LoginUrlAuthenticationEntryPoint("/login"), new MediaTypeRequestMatcher(MediaType.TEXT_HTML)))
			.apply(authorizationServerConfigurer);

		return http.build();
	}

	@Bean
	public KeyPair keyPair(SecurityProperties securityProperties)
			throws NoSuchAlgorithmException, InvalidKeySpecException {
		String publicKey = securityProperties.getPublicKey();
		String privateKey = securityProperties.getPrivateKey();
		return RSAUtils.create(publicKey, privateKey);
	}

	@Bean
	public JWKSource<SecurityContext> jwkSource(KeyPair keyPair) {
		RSAKey rsaKey = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()).privateKey(keyPair.getPrivate()).build();
		JWKSet jwkSet = new JWKSet(rsaKey);
		return new ImmutableJWKSet<>(jwkSet);
	}

	@Bean
	public RegisteredClientRepository registeredClientRepository(JdbcOperations jdbcOperations) {
		return new JdbcRegisteredClientRepository(jdbcOperations);
	}

	@Bean
	public OAuth2AuthorizationService authorizationService(JdbcOperations jdbcOperations,
			RegisteredClientRepository registeredClientRepository) {
		return new JdbcOAuth2AuthorizationService(jdbcOperations, registeredClientRepository);
	}

	@Bean
	public OAuth2AuthorizationConsentService authorizationConsentService(JdbcOperations jdbcOperations,
			RegisteredClientRepository registeredClientRepository) {
		return new JdbcOAuth2AuthorizationConsentService(jdbcOperations, registeredClientRepository);
	}

	@Bean
	public AuthorizationServerSettings authorizationServerSettings() {
		return AuthorizationServerSettings.builder().build();
	}

}

ResourceServerConfig.java

package cn.com.xuxiaowei.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;

import javax.sql.DataSource;
import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;

/**
 * @author xuxiaowei
 */
@Slf4j
@Configuration(proxyBeanMethods = false)
public class ResourceServerConfig {

	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http, KeyPair keyPair) throws Exception {

		http.authorizeHttpRequests(authorize -> authorize
			//
			.requestMatchers("/favicon.ico")
			.permitAll()
			//
			.requestMatchers("/index")
			.permitAll()
			//
			.requestMatchers("/error")
			.permitAll()
			//
			.anyRequest()
			.authenticated());

		http.formLogin(customizer -> {
			customizer
				//
				.loginProcessingUrl("/login")
				// 登录成功后默认重定向地址
				.defaultSuccessUrl("/")
				//
				.failureForwardUrl("/login/error")
				// 登录成功请求转发地址
				.successForwardUrl("/login/success");
		});

		http.oauth2ResourceServer(customizer -> {
			customizer.jwt(jwtCustomizer -> {
				NimbusJwtDecoder.PublicKeyJwtDecoderBuilder publicKeyJwtDecoderBuilder = NimbusJwtDecoder
					.withPublicKey((RSAPublicKey) keyPair.getPublic());
				NimbusJwtDecoder nimbusJwtDecoder = publicKeyJwtDecoderBuilder.build();
				jwtCustomizer.decoder(nimbusJwtDecoder);
			});
		});

		http.csrf(AbstractHttpConfigurer::disable);

		return http.build();
	}

	@Bean
	public UserDetailsService userDetailsService(DataSource dataSource) {
		return new JdbcUserDetailsManager(dataSource);
	}

}

AuthorizedRestController.java

  • 当且仅当在前端无法获取重定向地址时使用
  • Java 测试类可以直接获取到重定向地址,所以不需要使用此接口
  • uni-app x Android 端,无法获取重定向地址,需要使用此接口
package cn.com.xuxiaowei.controller;

import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/authorized")
public class AuthorizedRestController {

	@GetMapping
	public Map<String, String> authorized(HttpServletRequest request) {
		String code = request.getParameter("code");
		String state = request.getParameter("state");
		Map<String, String> map = new HashMap<>();
		map.put("code", code);
		map.put("state", state);
		return map;
	}

}

IndexRestController.java

package cn.com.xuxiaowei.controller;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
public class IndexRestController {

	/**
	 * 登录成功后默认重定向地址
	 */
	@RequestMapping
	public Map<String, Object> index() {
		return Map.of();
	}

}

LoginRestController.java

package cn.com.xuxiaowei.controller;

import cn.com.xuxiaowei.properties.SecurityProperties;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;

@RestController
@RequestMapping("/login")
public class LoginRestController {

	private SecurityProperties securityProperties;

	@Autowired
	public void setSecurityProperties(SecurityProperties securityProperties) {
		this.securityProperties = securityProperties;
	}

	@ResponseStatus(HttpStatus.UNAUTHORIZED)
	@RequestMapping("/error")
	public Map<String, Object> error() {
		return Map.of();
	}

	/**
	 * 用于在 `uni-app x` Android 端登录完成后,PKCE 授权时使用
	 */
	@RequestMapping("/success")
	public Map<String, String> success() {
		String clientId = securityProperties.getClientIdRequireProofKey();
		String scope = securityProperties.getScope();
		String redirectUri = securityProperties.getRedirectUri();
		Map<String, String> map = new HashMap<>();
		map.put("clientId", clientId);
		map.put("scope", scope);
		map.put("redirectUri", redirectUri);
		return map;
	}

}

SecurityProperties.java

package cn.com.xuxiaowei.properties;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

@Data
@Component
@ConfigurationProperties(prefix = "security")
public class SecurityProperties {

	/**
	 * Spring Security OAuth 2.1 JWT 验证签名的 RSA 公钥
	 */
	private String publicKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAueyYpHyBtH5uAb4UiVS/5nxGoFrStxRAttLoPkGrRmdQ4qMjusDfyw1WI9VCIhFPAasL5SgUttYMOSeUOGjUAXynBB/gMfPjOJZUxcRlSUYVjyvKsV2wTtPA8IGbpzeUKZQFSj1Oooy2tSlWYRigOkm7VlPVnVWn2TpkSl8EuNUP5CYbE+aXwMHHP2UvVRQfGB4JKBra0XplalqlPlS2goO3Gz4TKsjrrdcItm7A4h3s37gUGC7C7yzwse7q5gbTIfrMqk2u1YGdxTUuSsMGE+8MFllFFcLBxgNVy61nHVCEXU+JlX9NYAUikQg8zkRLwjgqORUajvXIBFG2fxuG1QIDAQAB";

	/**
	 * Spring Security OAuth 2.1 JWT 生成签名的 RSA 私钥
	 */
	private String privateKey = "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC57JikfIG0fm4BvhSJVL/mfEagWtK3FEC20ug+QatGZ1DioyO6wN/LDVYj1UIiEU8BqwvlKBS21gw5J5Q4aNQBfKcEH+Ax8+M4llTFxGVJRhWPK8qxXbBO08DwgZunN5QplAVKPU6ijLa1KVZhGKA6SbtWU9WdVafZOmRKXwS41Q/kJhsT5pfAwcc/ZS9VFB8YHgkoGtrRemVqWqU+VLaCg7cbPhMqyOut1wi2bsDiHezfuBQYLsLvLPCx7urmBtMh+syqTa7VgZ3FNS5KwwYT7wwWWUUVwsHGA1XLrWcdUIRdT4mVf01gBSKRCDzOREvCOCo5FRqO9cgEUbZ/G4bVAgMBAAECggEABJyHLrE94FWwcc+en8df2R4k/E40YsEYV9CEXSLw1hay7WQhezzUcCtdAxeDg+fM/1wYN+9WEDDf7bz7Eqka9Qx78gC+ZU7IyHsGED+uSXJ5D2uFJAAQYuwioXR9gVjCDoPy26QIosR9taGYWGEtfDSe9mWu6y+YMa2eli+kJNdtJSG+RpRz8UB1cCHpehNAy+S0pOAKPQuqwRrzjfTbALM84eqB/lVGJKwmfpi5uwYwjyi2gqhiz2jLYae1Kb2LOxcMe2hfKnnIt1NoZYM7wNci2LXzWiU3fJg3ysXoOkO7sfCZ7SlOHIE9E9Wdm/7abHf2DaPAi4mDH+oij+rlAwKBgQDwnh0pFCwP0VW3N7RgyblwQYVHPNAw0UIGI02oW/a7zPBMuWLhRLpG61e+XWJgOzqPV9fyLWRrAOwniwBRsjTb8Ft26Al5dZuFx68ldNNFKvwDhF9pXQ+vyRyknH6wUtDu54/u3Mv7mfGt1XRlL3VtHGoEk+gvy4cFDcON0fb2DwKBgQDFz2JBt3vEiWJY2tRh4NXIF6PGoY6ujfnhUkdkNlLN0NzK+mcPbvPfv4mUaHKdo9RkAv1mIXSsiPhzuMD0ed7iYlJPn6REC5IfD2ERbnH0tmmYEVawUWANXySGWTNwSYUuWwHZmO4FL02OjLGd4fvEX/oBK5+yuutPiNKXioB42wKBgQDCBs3+5QxOyP/0mU+zyJbnJX6CnlBHPUafSnKBs363m5+eTtOkUVZgf8AmeoksjjY/hpdU6zORcZH8pQLh3fDv9dbbgGq7bZG2g/oBGz6OBQZpE6IYhXlzx5l4R9WE+5MNQt72v0choNaY1YphWa64CHSZMmfFuroq4hlx0AD0EwKBgGpJSlRhUKGD6FIyEtgcxQHkod2CxXXJV7DYUv/nqIpqZZiy/1ltlqBs/HG/xYYql169tIaCB30Fg+o6JYO3UCl4Bx49ezgMt5D05IVHQPfqY8aP2nKW5vOIYcnGeDsnZeZIhC/1Wj9y8UtdEbrxyCP2JhEm7YJNqU5tCCrhArLtAoGAftauETQEbaP/0WPlUDXHShvkWNwmSp+kZzJBzFwqxk45aS8D8lNt6scM6vGGlgZ3fLspSmO3v+nD7BEtAyy8N9M9pBshi3nQbSTu7Jo5RvXgXNxBg+TVs4oAocYZxJdfa2b9wumpKQcqz8278AjUg75iQrJqlenE8qGAJApKROc=";

	/**
	 * Spring Security OAuth 2.1 PKCE 客户 ID
	 */
	private String clientIdRequireProofKey = "b622c4b7-3cbe-4686-b534-d015b5bd96be";

	/**
	 * Spring Security OAuth 2.1 PKCE 客户 申请范围
	 */
	private String scope = "openid";

	/**
	 * Spring Security OAuth 2.1 PKCE 客户 重定向地址
	 */
	private String redirectUri = "http://172.25.25.24:51234/authorized";

}

RSAUtils.java

package cn.com.xuxiaowei.util;

import java.security.*;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.util.Base64;

/**
 * @author xuxiaowei
 */
public class RSAUtils {

	private static final String RSA = "RSA";

	public static KeyPair generate() throws NoSuchAlgorithmException {
		return generate(2048);
	}

	public static KeyPair generate(int keysize) throws NoSuchAlgorithmException {
		KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA);
		keyPairGenerator.initialize(keysize);
		return keyPairGenerator.generateKeyPair();
	}

	public static KeyPair create(String publicKey, String privateKey)
			throws NoSuchAlgorithmException, InvalidKeySpecException {
		return new KeyPair(publicKey(publicKey), privateKey(privateKey));
	}

	public static String publicKey(PublicKey publicKey) {
		return Base64.getEncoder().encodeToString(publicKey.getEncoded());
	}

	public static PublicKey publicKey(String publicKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
		byte[] publicKeyBytes = Base64.getDecoder().decode(publicKey);
		X509EncodedKeySpec keySpec = new X509EncodedKeySpec(publicKeyBytes);
		KeyFactory keyFactory = KeyFactory.getInstance(RSA);
		return keyFactory.generatePublic(keySpec);
	}

	public static String privateKey(PrivateKey privateKey) {
		return Base64.getEncoder().encodeToString(privateKey.getEncoded());
	}

	public static PrivateKey privateKey(String privateKey) throws NoSuchAlgorithmException, InvalidKeySpecException {
		byte[] privateKeyBytes = Base64.getDecoder().decode(privateKey);
		PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(privateKeyBytes);
		KeyFactory keyFactory = KeyFactory.getInstance(RSA);
		return keyFactory.generatePrivate(keySpec);
	}

}

SpringBootOauth2PkceApplication.java

package cn.com.xuxiaowei;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class SpringBootOauth2PkceApplication {

	public static void main(String[] args) {
		SpringApplication.run(SpringBootOauth2PkceApplication.class, args);
	}

}

application.yml

server:
  port: ${PORT:51234}

---

spring:
  application:
    name: spring-boot-oauth2-pkce

---

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: ${DATABASE_URL:jdbc:mysql://${DATABASE_HOST:localhost}:${DATABASE_PORT:3306}/${DATABASE_NAME:spring_boot_oauth2_pkce}}
    username: ${DATABASE_USERNAME:root}
    password: ${DATABASE_PASSWORD:xuxiaowei.com.cn}
  sql:
    init:
      mode: ${DATABASE_MODE:EMBEDDED}
      schema-locations: classpath:schema/schema.sql
      data-locations: classpath:data/data.sql

---

logging:
  level:
    web: debug

schema.sql

/*
 Navicat Premium Dump SQL

 Source Server         : localhost
 Source Server Type    : MySQL
 Source Server Version : 80036 (8.0.36)
 Source Host           : localhost:3306
 Source Schema         : spring_boot_oauth2_pkce

 Target Server Type    : MySQL
 Target Server Version : 80036 (8.0.36)
 File Encoding         : 65001

 Date: 10/01/2026 16:54:02
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for authorities
-- ----------------------------
DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities` (
  `username` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  `authority` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  UNIQUE KEY `ix_auth_username` (`username`,`authority`),
  CONSTRAINT `fk_authorities_users` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for oauth2_authorization
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_authorization`;
CREATE TABLE `oauth2_authorization` (
  `id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `registered_client_id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `principal_name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL,
  `authorization_grant_type` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `authorized_scopes` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `attributes` blob,
  `state` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `authorization_code_value` blob,
  `authorization_code_issued_at` timestamp NULL DEFAULT NULL,
  `authorization_code_expires_at` timestamp NULL DEFAULT NULL,
  `authorization_code_metadata` blob,
  `access_token_value` blob,
  `access_token_issued_at` timestamp NULL DEFAULT NULL,
  `access_token_expires_at` timestamp NULL DEFAULT NULL,
  `access_token_metadata` blob,
  `access_token_type` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `access_token_scopes` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `oidc_id_token_value` blob,
  `oidc_id_token_issued_at` timestamp NULL DEFAULT NULL,
  `oidc_id_token_expires_at` timestamp NULL DEFAULT NULL,
  `oidc_id_token_metadata` blob,
  `refresh_token_value` blob,
  `refresh_token_issued_at` timestamp NULL DEFAULT NULL,
  `refresh_token_expires_at` timestamp NULL DEFAULT NULL,
  `refresh_token_metadata` blob,
  `user_code_value` blob,
  `user_code_issued_at` timestamp NULL DEFAULT NULL,
  `user_code_expires_at` timestamp NULL DEFAULT NULL,
  `user_code_metadata` blob,
  `device_code_value` blob,
  `device_code_issued_at` timestamp NULL DEFAULT NULL,
  `device_code_expires_at` timestamp NULL DEFAULT NULL,
  `device_code_metadata` blob,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for oauth2_authorization_consent
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_authorization_consent`;
CREATE TABLE `oauth2_authorization_consent` (
  `registered_client_id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `principal_name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL,
  `authorities` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`registered_client_id`,`principal_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for oauth2_registered_client
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_registered_client`;
CREATE TABLE `oauth2_registered_client` (
  `id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `client_id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `client_id_issued_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `client_secret` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `client_secret_expires_at` timestamp NULL DEFAULT NULL,
  `client_name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL,
  `client_authentication_methods` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  `authorization_grant_types` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  `redirect_uris` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `post_logout_redirect_uris` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `scopes` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  `client_settings` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL,
  `token_settings` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `username` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  `password` varchar(500) COLLATE utf8mb4_general_ci NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

SET FOREIGN_KEY_CHECKS = 1;

data.sql

/*
 Navicat Premium Dump SQL

 Source Server         : localhost
 Source Server Type    : MySQL
 Source Server Version : 80036 (8.0.36)
 Source Host           : localhost:3306
 Source Schema         : spring_boot_oauth2_pkce

 Target Server Type    : MySQL
 Target Server Version : 80036 (8.0.36)
 File Encoding         : 65001

 Date: 10/01/2026 16:54:02
*/

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for authorities
-- ----------------------------
DROP TABLE IF EXISTS `authorities`;
CREATE TABLE `authorities` (
  `username` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  `authority` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  UNIQUE KEY `ix_auth_username` (`username`,`authority`),
  CONSTRAINT `fk_authorities_users` FOREIGN KEY (`username`) REFERENCES `users` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for oauth2_authorization
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_authorization`;
CREATE TABLE `oauth2_authorization` (
  `id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `registered_client_id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `principal_name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL,
  `authorization_grant_type` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `authorized_scopes` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `attributes` blob,
  `state` varchar(500) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `authorization_code_value` blob,
  `authorization_code_issued_at` timestamp NULL DEFAULT NULL,
  `authorization_code_expires_at` timestamp NULL DEFAULT NULL,
  `authorization_code_metadata` blob,
  `access_token_value` blob,
  `access_token_issued_at` timestamp NULL DEFAULT NULL,
  `access_token_expires_at` timestamp NULL DEFAULT NULL,
  `access_token_metadata` blob,
  `access_token_type` varchar(100) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `access_token_scopes` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `oidc_id_token_value` blob,
  `oidc_id_token_issued_at` timestamp NULL DEFAULT NULL,
  `oidc_id_token_expires_at` timestamp NULL DEFAULT NULL,
  `oidc_id_token_metadata` blob,
  `refresh_token_value` blob,
  `refresh_token_issued_at` timestamp NULL DEFAULT NULL,
  `refresh_token_expires_at` timestamp NULL DEFAULT NULL,
  `refresh_token_metadata` blob,
  `user_code_value` blob,
  `user_code_issued_at` timestamp NULL DEFAULT NULL,
  `user_code_expires_at` timestamp NULL DEFAULT NULL,
  `user_code_metadata` blob,
  `device_code_value` blob,
  `device_code_issued_at` timestamp NULL DEFAULT NULL,
  `device_code_expires_at` timestamp NULL DEFAULT NULL,
  `device_code_metadata` blob,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for oauth2_authorization_consent
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_authorization_consent`;
CREATE TABLE `oauth2_authorization_consent` (
  `registered_client_id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `principal_name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL,
  `authorities` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`registered_client_id`,`principal_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for oauth2_registered_client
-- ----------------------------
DROP TABLE IF EXISTS `oauth2_registered_client`;
CREATE TABLE `oauth2_registered_client` (
  `id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `client_id` varchar(100) COLLATE utf8mb4_general_ci NOT NULL,
  `client_id_issued_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `client_secret` varchar(200) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `client_secret_expires_at` timestamp NULL DEFAULT NULL,
  `client_name` varchar(200) COLLATE utf8mb4_general_ci NOT NULL,
  `client_authentication_methods` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  `authorization_grant_types` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  `redirect_uris` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `post_logout_redirect_uris` varchar(1000) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `scopes` varchar(1000) COLLATE utf8mb4_general_ci NOT NULL,
  `client_settings` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL,
  `token_settings` varchar(2000) COLLATE utf8mb4_general_ci NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users` (
  `username` varchar(50) COLLATE utf8mb4_general_ci NOT NULL,
  `password` varchar(500) COLLATE utf8mb4_general_ci NOT NULL,
  `enabled` tinyint(1) NOT NULL,
  PRIMARY KEY (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

SET FOREIGN_KEY_CHECKS = 1;

TestRestController.java

package cn.com.xuxiaowei.controller;

import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

/**
 * @author xuxiaowei
 */
@Slf4j
@RestController
@RequestMapping("/test")
public class TestRestController {

	@RequestMapping(method = { RequestMethod.GET, RequestMethod.POST })
	public Map<String, Object> index(Authentication authentication) {
		String name = authentication.getName();
		log.info("name:{}", name);
		return Map.of("name", name);
	}

}

OAuth2ClientCredentialsTests

package cn.com.xuxiaowei.security;

import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.jdbc.core.JdbcTemplate;
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.OAuth2TokenIntrospectionClaimNames;
import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames;
import org.springframework.security.oauth2.core.oidc.OidcScopes;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
import org.springframework.security.oauth2.server.authorization.OAuth2Authorization;
import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService;
import org.springframework.security.oauth2.server.authorization.OAuth2TokenType;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClient;
import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository;
import org.springframework.security.oauth2.server.authorization.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectWriter;

import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.Base64;
import java.util.Map;
import java.util.UUID;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class OAuth2ClientCredentialsTests {

	private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

	private static ObjectWriter OBJECT_WRITER;

	{
		OBJECT_WRITER = OBJECT_MAPPER.writerWithDefaultPrettyPrinter();
	}

	@LocalServerPort
	private int port;

	@Autowired
	private OAuth2AuthorizationService authorizationService;

	@Autowired
	private RegisteredClientRepository registeredClientRepository;

	@Autowired
	private JdbcTemplate jdbcTemplate;

	@Autowired
	private KeyPair keyPair;

	private RegisteredClient registeredClient;

	private PasswordEncoder passwordEncoder;

	private String clientId;

	private String clientSecret;

	@BeforeEach
	void setUp() {
		clientId = "test-client-" + UUID.randomUUID();
		clientSecret = "test-secret-" + UUID.randomUUID();
		passwordEncoder = PasswordEncoderFactories.createDelegatingPasswordEncoder();
		registeredClient = createClient(clientId, clientSecret);
		log.info("Created test client: {}", clientId);
	}

	@AfterEach
	void tearDown() {
		cleanupClient();
		log.info("Cleaned up test client: {}", clientId);
	}

	@Test
	void testClientCredentials() {
		assertNotNull(registeredClient, "Client should be created");
		assertEquals(clientId, registeredClient.getClientId(), "Client ID should match");
		assertTrue(
				registeredClient.getClientAuthenticationMethods()
					.contains(ClientAuthenticationMethod.CLIENT_SECRET_BASIC),
				"Client should support client_secret_basic authentication");
		assertTrue(registeredClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.CLIENT_CREDENTIALS),
				"Client should support client_credentials grant type");

		String accessToken;
		{
			RestTemplate restTemplate = new RestTemplate();
			HttpHeaders httpHeaders = new HttpHeaders();
			httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
			httpHeaders.setBasicAuth(clientId, clientSecret);
			MultiValueMap<@NonNull String, String> requestBody = new LinkedMultiValueMap<>();
			requestBody.add(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.CLIENT_CREDENTIALS.getValue());
			requestBody.add(OAuth2ParameterNames.SCOPE, "read write " + OidcScopes.OPENID);
			HttpEntity<@NonNull MultiValueMap<@NonNull String, String>> httpEntity = new HttpEntity<>(requestBody,
					httpHeaders);

			Map<?, ?> map = restTemplate.postForObject(String.format("http://127.0.0.1:%d/oauth2/token", port),
					httpEntity, Map.class);

			assertNotNull(map);

			log.info("token:\n{}", OBJECT_WRITER.writeValueAsString(map));

			assertNotNull(map.get(OAuth2ParameterNames.ACCESS_TOKEN));
			assertNotNull(map.get(OAuth2ParameterNames.SCOPE));
			assertNotNull(map.get(OAuth2ParameterNames.TOKEN_TYPE));
			assertNotNull(map.get(OAuth2ParameterNames.EXPIRES_IN));

			assertEquals(4, map.size());

			accessToken = map.get(OAuth2ParameterNames.ACCESS_TOKEN).toString();
		}

		{
			String[] split = accessToken.split("\\.");
			assertEquals(3, split.length);

			String payloadEncode = split[1];

			String payloadDecode = new String(Base64.getDecoder().decode(payloadEncode), StandardCharsets.UTF_8);

			Map<String, Object> payload = OBJECT_MAPPER.readValue(payloadDecode, new TypeReference<>() {
			});

			log.info("payload:\n{}", OBJECT_WRITER.writeValueAsString(payload));

			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.SUB));
			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.AUD));
			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.NBF));
			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.SCOPE));
			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.ISS));
			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.EXP));
			assertNotNull(payload.get(OAuth2TokenIntrospectionClaimNames.IAT));

			// 凭证式模式:
			// aud:代表客户ID
			// sub:代表用户名,由于凭证式是自己给自己授权,所以 sub 和 aud 相同,都是 客户ID
			assertEquals(clientId, payload.get(OAuth2TokenIntrospectionClaimNames.AUD));
			assertEquals(clientId, payload.get(OAuth2TokenIntrospectionClaimNames.SUB));
		}

		{
			NimbusJwtDecoder.PublicKeyJwtDecoderBuilder publicKeyJwtDecoderBuilder = NimbusJwtDecoder
				.withPublicKey((RSAPublicKey) keyPair.getPublic());
			NimbusJwtDecoder nimbusJwtDecoder = publicKeyJwtDecoderBuilder.build();
			Jwt jwt = nimbusJwtDecoder.decode(accessToken);
			log.info("jwt: \n{}", OBJECT_WRITER.writeValueAsString(jwt));
		}

		{
			OAuth2Authorization authorization = authorizationService.findByToken(accessToken,
					OAuth2TokenType.ACCESS_TOKEN);
			assertNotNull(authorization);
			log.info("authorization: \n{}", OBJECT_WRITER.writeValueAsString(authorization));
		}

		{
			RestTemplate restTemplate = new RestTemplate();
			HttpHeaders httpHeaders = new HttpHeaders();
			httpHeaders.setBearerAuth(accessToken);
			MultiValueMap<@NonNull String, String> requestBody = new LinkedMultiValueMap<>();
			HttpEntity<@NonNull MultiValueMap<@NonNull String, String>> httpEntity = new HttpEntity<>(requestBody,
					httpHeaders);

			Map<?, ?> map = restTemplate.postForObject(String.format("http://127.0.0.1:%d/test", port), httpEntity,
					Map.class);

			assertNotNull(map);
			assertNotNull(map.get("name"));
			assertEquals(clientId, map.get("name"));
		}
	}

	private RegisteredClient createClient(String clientId, String clientSecret) {
		RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
			.clientId(clientId)
			.clientSecret(passwordEncoder.encode(clientSecret))
			.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
			.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
			.scope(OidcScopes.OPENID)
			.scope("read")
			.scope("write")
			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build())
			.tokenSettings(TokenSettings.builder()
				.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
				.accessTokenTimeToLive(Duration.ofMinutes(30))
				.build())
			.build();

		registeredClientRepository.save(registeredClient);

		RegisteredClient savedClient = registeredClientRepository.findByClientId(clientId);
		assertNotNull(savedClient, "Client should be saved");
		return savedClient;
	}

	private void cleanupClient() {
		jdbcTemplate.update("DELETE FROM oauth2_authorization WHERE principal_name = ?", clientId);
		jdbcTemplate.update("DELETE FROM oauth2_registered_client WHERE client_id = ?", clientId);
	}

}

LoginTests

package cn.com.xuxiaowei.security;

import lombok.extern.slf4j.Slf4j;
import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.oauth2.core.ClientAuthenticationMethod;
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.OAuth2TokenType;
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.settings.ClientSettings;
import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat;
import org.springframework.security.oauth2.server.authorization.settings.TokenSettings;
import org.springframework.security.provisioning.UserDetailsManager;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.ObjectWriter;

import java.net.URI;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import java.util.*;

import static org.junit.jupiter.api.Assertions.*;

@Slf4j
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class LoginTests {

	private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

	private static ObjectWriter OBJECT_WRITER;

	{
		OBJECT_WRITER = OBJECT_MAPPER.writerWithDefaultPrettyPrinter();
	}

	@LocalServerPort
	private int port;

	@Autowired
	private RegisteredClientRepository registeredClientRepository;

	@Autowired
	private OAuth2AuthorizationService authorizationService;

	@Autowired
	private UserDetailsService userDetailsService;

	@Autowired
	private UserDetailsManager userDetailsManager;

	@Autowired
	private JdbcTemplate jdbcTemplate;

	@Autowired
	private KeyPair keyPair;

	private User user;

	private RegisteredClient registeredAppClient;

	private String clientId;

	private String username;

	private String password;

	@BeforeEach
	void setUp() {
		clientId = "test-app-client-" + UUID.randomUUID();
		username = "test-username-" + UUID.randomUUID();
		password = "test-password-" + UUID.randomUUID();
		user = createUser(username, password);
		registeredAppClient = createAppClient(clientId);
		log.info("Created test username: {}", username);
	}

	@AfterEach
	void tearDown() {
		cleanupClient();
		log.info("Cleaned up test client: {}", clientId);
		cleanupUser();
		log.info("Cleaned up test username: {}", username);
	}

	@Test
	void testUsernameLogin() {
		assertNotNull(user, "username should be created");
		assertEquals(username, user.getUsername(), "username should match");

		{
			RestTemplate restTemplate = new RestTemplate();
			HttpHeaders httpHeaders = new HttpHeaders();
			httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
			MultiValueMap<@NonNull String, String> requestBody = new LinkedMultiValueMap<>();
			requestBody.add("username", username);
			requestBody.add("password", password);
			HttpEntity<@NonNull MultiValueMap<@NonNull String, String>> httpEntity = new HttpEntity<>(requestBody,
					httpHeaders);

			String loginUrl = "http://localhost:" + port + "/login";
			ResponseEntity<Map<String, Object>> entity = restTemplate.exchange(loginUrl, HttpMethod.POST, httpEntity,
					new ParameterizedTypeReference<>() {
					});

			assertNotNull(entity);
			assertEquals(HttpStatus.OK, entity.getStatusCode());
			HttpHeaders headers = entity.getHeaders();
			assertNotNull(headers);
		}
	}

	@Test
	void testAppLoginAuthorizationCodePkce() throws NoSuchAlgorithmException {
		assertNotNull(user, "username should be created");
		assertEquals(username, user.getUsername(), "username should match");

		assertNotNull(registeredAppClient, "App client should be created");
		assertTrue(registeredAppClient.getClientAuthenticationMethods().contains(ClientAuthenticationMethod.NONE),
				"App client should be public (no client auth)");
		assertTrue(registeredAppClient.getAuthorizationGrantTypes().contains(AuthorizationGrantType.AUTHORIZATION_CODE),
				"App client should support authorization_code");

		String jsessionId;
		{
			RestTemplate restTemplate = new RestTemplate();
			HttpHeaders httpHeaders = new HttpHeaders();
			httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
			MultiValueMap<@NonNull String, String> requestBody = new LinkedMultiValueMap<>();
			requestBody.add("username", username);
			requestBody.add("password", password);
			HttpEntity<@NonNull MultiValueMap<@NonNull String, String>> httpEntity = new HttpEntity<>(requestBody,
					httpHeaders);

			String loginUrl = "http://localhost:" + port + "/login";
			ResponseEntity<Map<String, Object>> entity = restTemplate.exchange(loginUrl, HttpMethod.POST, httpEntity,
					new ParameterizedTypeReference<>() {
					});

			assertNotNull(entity);
			assertEquals(HttpStatus.OK, entity.getStatusCode());
			HttpHeaders headers = entity.getHeaders();
			assertNotNull(headers);

			String setCookie = Optional.ofNullable(headers.getFirst(HttpHeaders.SET_COOKIE)).orElse("");
			assertTrue(setCookie.contains("JSESSIONID"), "Login response should contain JSESSIONID");
			jsessionId = extractSessionId(setCookie);
			assertNotNull(jsessionId, "JSESSIONID should be extracted");
		}

		String redirectUri = String.format("http://127.0.0.1:%d/authorized", port);
		String state = UUID.randomUUID().toString();
		String codeVerifier = UUID.randomUUID().toString().replace("-", "")
				+ UUID.randomUUID().toString().replace("-", "");
		String codeChallenge = s256(codeVerifier);

		String authorizationCode;
		{
			RestTemplate restTemplate = new RestTemplate();
			HttpHeaders httpHeaders = new HttpHeaders();
			httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
			httpHeaders.add(HttpHeaders.COOKIE, "JSESSIONID=" + jsessionId);
			MultiValueMap<@NonNull String, String> requestBody = new LinkedMultiValueMap<>();
			requestBody.add(OAuth2ParameterNames.RESPONSE_TYPE, "code");
			requestBody.add(OAuth2ParameterNames.CLIENT_ID, clientId);
			requestBody.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri);
			requestBody.add(OAuth2ParameterNames.SCOPE, "read write " + OidcScopes.OPENID);
			requestBody.add(OAuth2ParameterNames.STATE, state);
			requestBody.add(PkceParameterNames.CODE_CHALLENGE, codeChallenge);
			requestBody.add(PkceParameterNames.CODE_CHALLENGE_METHOD, "S256");
			HttpEntity<@NonNull MultiValueMap<@NonNull String, String>> httpEntity = new HttpEntity<>(requestBody,
					httpHeaders);

			ResponseEntity<Void> entity = restTemplate.exchange(
					String.format("http://127.0.0.1:%d/oauth2/authorize", port), HttpMethod.POST, httpEntity,
					Void.class);
			assertNotNull(entity);
			assertEquals(HttpStatus.FOUND, entity.getStatusCode());
			HttpHeaders headers = entity.getHeaders();
			assertNotNull(headers);
			URI location = headers.getLocation();
			assertNotNull(location);
			assertTrue(location.toString().startsWith(redirectUri), "Should redirect to redirectUri");

			Map<String, String> params = QueryUtils.splitQuery(location);
			assertEquals(state, params.get("state"), "state should match");
			assertNotNull(params.get("code"), "code should be present");
			authorizationCode = params.get("code");
		}

		String accessToken;
		{
			RestTemplate restTemplate = new RestTemplate();
			HttpHeaders httpHeaders = new HttpHeaders();
			httpHeaders.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
			MultiValueMap<@NonNull String, String> requestBody = new LinkedMultiValueMap<>();
			requestBody.add(OAuth2ParameterNames.GRANT_TYPE, AuthorizationGrantType.AUTHORIZATION_CODE.getValue());
			requestBody.add(OAuth2ParameterNames.CODE, authorizationCode);
			requestBody.add(OAuth2ParameterNames.REDIRECT_URI, redirectUri);
			requestBody.add(OAuth2ParameterNames.CLIENT_ID, clientId);
			requestBody.add(PkceParameterNames.CODE_VERIFIER, codeVerifier);
			HttpEntity<@NonNull MultiValueMap<@NonNull String, String>> httpEntity = new HttpEntity<>(requestBody,
					httpHeaders);

			Map<?, ?> map = restTemplate.postForObject(String.format("http://127.0.0.1:%d/oauth2/token", port),
					httpEntity, Map.class);
			assertNotNull(map);
			log.info("token:\n{}", OBJECT_WRITER.writeValueAsString(map));
			assertNotNull(map.get(OAuth2ParameterNames.ACCESS_TOKEN));
			assertNotNull(map.get(OAuth2ParameterNames.SCOPE));
			assertNotNull(map.get(OAuth2ParameterNames.TOKEN_TYPE));
			assertNotNull(map.get(OAuth2ParameterNames.EXPIRES_IN));
			accessToken = map.get(OAuth2ParameterNames.ACCESS_TOKEN).toString();
		}

		{
			String[] split = accessToken.split("\\.");
			assertEquals(3, split.length);
			String payloadEncode = split[1];
			String payloadDecode = new String(Base64.getDecoder().decode(payloadEncode), StandardCharsets.UTF_8);
			Map<String, Object> payload = OBJECT_MAPPER.readValue(payloadDecode,
					new tools.jackson.core.type.TypeReference<>() {
					});
			log.info("payload:\n{}", OBJECT_WRITER.writeValueAsString(payload));
			assertNotNull(payload.get(OAuth2ParameterNames.SCOPE));
		}

		{
			org.springframework.security.oauth2.jwt.NimbusJwtDecoder.PublicKeyJwtDecoderBuilder publicKeyJwtDecoderBuilder = org.springframework.security.oauth2.jwt.NimbusJwtDecoder
				.withPublicKey((RSAPublicKey) keyPair.getPublic());
			org.springframework.security.oauth2.jwt.NimbusJwtDecoder nimbusJwtDecoder = publicKeyJwtDecoderBuilder
				.build();
			org.springframework.security.oauth2.jwt.Jwt jwt = nimbusJwtDecoder.decode(accessToken);
			log.info("jwt: \n{}", OBJECT_WRITER.writeValueAsString(jwt));
		}

		{
			OAuth2Authorization authorization = authorizationService.findByToken(accessToken,
					OAuth2TokenType.ACCESS_TOKEN);
			assertNotNull(authorization);
			log.info("authorization: \n{}", OBJECT_WRITER.writeValueAsString(authorization));
		}

		// {
		// Optional<cn.com.xuxiaowei.entity.OAuth2AuthorizationEntity>
		// optionalAuthorization =
		// oauth2AuthorizationRepository.findByPrincipalName(username);
		// assertTrue(optionalAuthorization.isPresent(), "oauth2_authorization should
		// contain principal authorization");
		// }
	}

	private RegisteredClient createAppClient(String clientId) {
		RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
			.clientId(clientId)
			.clientAuthenticationMethod(ClientAuthenticationMethod.NONE)
			.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
			.redirectUri(String.format("http://127.0.0.1:%d/authorized", port))
			.scope(OidcScopes.OPENID)
			.scope("read")
			.scope("write")
			.clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).requireProofKey(true).build())
			.tokenSettings(TokenSettings.builder()
				.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
				.accessTokenTimeToLive(Duration.ofMinutes(30))
				.build())
			.build();

		registeredClientRepository.save(registeredClient);
		RegisteredClient saved = registeredClientRepository.findByClientId(clientId);
		assertNotNull(saved, "App client should be saved");
		return saved;
	}

	private static String s256(String codeVerifier) throws NoSuchAlgorithmException {
		MessageDigest md = MessageDigest.getInstance("SHA-256");
		byte[] digest = md.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
		return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
	}

	private static String extractSessionId(String setCookie) {
		for (String part : setCookie.split(";")) {
			String trimmed = part.trim();
			if (trimmed.startsWith("JSESSIONID=")) {
				return trimmed.substring("JSESSIONID=".length());
			}
		}
		return null;
	}

	static class QueryUtils {

		static Map<String, String> splitQuery(URI uri) {
			String query = uri.getQuery();
			HashMap<String, String> map = new HashMap<>();
			if (query == null || query.isEmpty()) {
				return map;
			}
			for (String pair : query.split("&")) {
				int idx = pair.indexOf("=");
				if (idx > 0) {
					String key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8);
					String value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8);
					map.put(key, value);
				}
			}
			return map;
		}

	}

	private User createUser(String username, String password) {
		Collection<? extends GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
		User user = new User(username, "{noop}" + password, authorities);

		userDetailsManager.createUser(user);

		UserDetails userDetails = userDetailsService.loadUserByUsername(username);
		assertEquals(userDetails.getUsername(), user.getUsername(), "User should be saved");

		return user;
	}

	private void cleanupClient() {
		jdbcTemplate.update("DELETE FROM oauth2_authorization WHERE principal_name = ?", clientId);
		jdbcTemplate.update("DELETE FROM oauth2_registered_client WHERE client_id = ?", clientId);
	}

	private void cleanupUser() {
		jdbcTemplate.update("DELETE FROM authorities WHERE username = ?", username);
		jdbcTemplate.update("DELETE FROM users WHERE username = ?", username);
	}

}