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 xAndroid 端,无法获取重定向地址,需要使用此接口
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);
}
}