Overview
OAuth2 divides clients into two types according to whether they can hold the client key: public clients and confidential clients.
The confidential client runs on the server, and the application created by Spring Boot in the previous OAuth2 article is an example of a confidential client type. First, they run on servers, often behind firewalls or gateways with other protections.
The code of the public client is generally exposed to the end user in some form, either downloaded and executed in the browser, or directly run on the user's device. For example, a native application is an application that runs directly on the end user's device (computer or mobile device). When this type of application uses the OAuth2 protocol, we cannot guarantee that the client key issued for this application can be stored securely, because these applications will be fully downloaded to the device before running, and decompiling the application will fully reveal the client key.
The same security issue exists with single page applications (SPA), the browser itself is an insecure environment, once you load a JavaScript application, the browser will download the entire source code in order to run it, the entire source code, including the Any client secrets will be visible. If you build an application with 100,000 users, chances are that some of those users will get malware or a virus and leak client keys.
You might be thinking, "What if I obfuscate the client key by splitting it into parts?" This will undeniably buy you some time, but a really determined person might still figure it out.
To circumvent this security risk, it is best to use Proof Key for Code Exchange (PKCE).
Proof Key for Code Exchange
PKCE has its own independent specification. It enables applications to use the authorization code flow in public clients.
- A user requests a resource on the client side.
- The client creates and records the secret information named code_verifier, and then the client calculates code_challenge based on code_verifier. Its value can be code_verifier or the SHA-256 hash of code_verifier, but cryptographic hashing should be preferred,because it prevents the authenticator itself from being intercepted.
- The client sends the code_challenge and optional code_challenge_method (a keyword representing the original text or SHA-256 hash) to the authorization server along with the normal authorization request parameters.
- The authorization server redirects the user to the login page.
- The user is authenticated and may see a consent page listing the permissions the authorization server will grant to the client.
- The authorization server records the code_challenge and code_challenge_method (if any). The authorization server will associate this information with the issued authorization code, and redirect back to the client with the code.
- After receiving the authorization code, the client carries the previously generated code_verifier to execute the token request.
- The authorization server calculates the code_challenge based on the code_verifier and checks whether it is consistent with the originally submitted code_challenge.
- Authorization server sends token to client.
- The client requests the protected resource using the token.
- A protected resource returns a resource to the client.
๐ก Note: If you donโt want to read till the end, you can view the source code here.Donโt forget to give a star to the project if you like it!
Use Spring Authorization Server to build an authorization server ๐
In this section, we will use Spring Authorization Server to build an authorization server and register a client to support PKCE.
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-oauth2-authorization-server</artifactId>
<version>0.3.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
Configuration
The first is very simple, we will create the application.yml
file and specify the authorization server port as 8080:
server:
port: 8080
Afterwards we will create an OAuth2ServerConfig
configuration class, and within this class we will create the specific beans required for the OAuth2 authorization service:
@Bean
@Order(Ordered.HIGHEST_PRECEDENCE)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
return http.exceptionHandling(exceptions -> exceptions.
authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login"))).build();
}
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("relive-client")
.clientAuthenticationMethods(s -> {
s.add(ClientAuthenticationMethod.NONE);//Client authentication mode is none
})
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.redirectUri("http://127.0.0.1:8070/login/oauth2/code/messaging-client-pkce")
.scope("message.read")
.clientSettings(ClientSettings.builder()
.requireAuthorizationConsent(true)
.requireProofKey(true) //Only PKCE is supported
.build())
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) // Generate JWT token
.idTokenSignatureAlgorithm(SignatureAlgorithm.RS256)
.accessTokenTimeToLive(Duration.ofSeconds(30 * 60))
.refreshTokenTimeToLive(Duration.ofSeconds(60 * 60))
.reuseRefreshTokens(true)
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
@Bean
public ProviderSettings providerSettings() {
return ProviderSettings.builder()
.issuer("http://127.0.0.1:8080")
.build();
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = Jwks.generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
static class Jwks {
private Jwks() {
}
public static RSAKey generateRsa() {
KeyPair keyPair = KeyGeneratorUtils.generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
}
static class KeyGeneratorUtils {
private KeyGeneratorUtils() {
}
static KeyPair generateRsaKey() {
KeyPair keyPair;
try {
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA");
keyPairGenerator.initialize(2048);
keyPair = keyPairGenerator.generateKeyPair();
} catch (Exception ex) {
throw new IllegalStateException(ex);
}
return keyPair;
}
}
When creating the RegisteredClient
client registration class:
- We did not define
client_secret
.
2.The client authentication mode is specified as none.
3.requireProofKey() is set to true, this client only supports PKCE.
I will not explain the rest of the configuration here, you can refer to the previous article.
Next, we create a Spring Security configuration class, specify Form form authentication and set username and password:
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorizeRequests ->
authorizeRequests.anyRequest().authenticated()
)
.formLogin(withDefaults());
return http.build();
}
@Bean
UserDetailsService users() {
UserDetails user = User.withDefaultPasswordEncoder()
.username("admin")
.password("password")
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
So far we have configured a simple authorization server. ๐
OAuth2.0 client ๐
In this section, we use Spring Security to create a client that requests authorization from the authorization server through the PKCE authorization code flow, and sends the obtained access_token to the resource service.
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-webflux</artifactId>
<version>5.3.9</version>
</dependency>
<dependency>
<groupId>io.projectreactor.netty</groupId>
<artifactId>reactor-netty</artifactId>
<version>1.0.9</version>
</dependency>
Configuration
First, we will configure the client information in application.yml
, and specify the service port number as 8070:
server:
port: 8070
servlet:
session:
cookie:
name: CLIENT-SESSION
spring:
security:
oauth2:
client:
registration:
messaging-client-pkce:
provider: client-provider
client-id: relive-client
client-secret: relive-client
authorization-grant-type: authorization_code
client-authentication-method: none
redirect-uri: "http://127.0.0.1:8070/login/oauth2/code/{registrationId}"
scope: message.read
client-name: messaging-client-pkce
provider:
client-provider:
authorization-uri: http://127.0.0.1:8080/oauth2/authorize
token-uri: http://127.0.0.1:8080/oauth2/token
Next, we create the Spring Security configuration class to enable the OAuth2 client.
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorizeRequests ->
//It is convenient for us to test, and the client service does not add authentication
authorizeRequests.anyRequest().permitAll()
)
.oauth2Client(withDefaults());
return http.build();
}
@Bean
WebClient webClient(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client = new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
return WebClient.builder()
.filter(oauth2Client)
.build();
}
@Bean
OAuth2AuthorizedClientManager authorizedClientManager(ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder
.builder()
.authorizationCode()
.refreshToken()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager = new DefaultOAuth2AuthorizedClientManager(clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
In the above configuration class, we enable the OAuth2 client through oauth2Client(withDefaults()). And create a WebClient
instance to perform HTTP requests to the resource server. OAuth2AuthorizedClientManager
is a high-level controller class that coordinates OAuth2 authorization code requests, but the authorization code process is not controlled by it. There is no relevant authorized code process logic in AuthorizationCodeOAuth2AuthorizedClientProvider
class, for the core interface process for Spring Secrity authorization code mode, I will introduce it in a later article.
Going back to the OAuth2AuthorizedClientManager
class, we can see that refreshToken() is also specified at the same time, which implements the refresh token logic and will refresh the token after the access_token expires in the process of requesting resource services, the premise is that the refresh_token has not expired, otherwise You will go through the OAuth2 authorization code flow again.
Next, we create a Controller class and use WebClient to request resource services:
@RestController
public class PkceClientController {
@Autowired
private WebClient webClient;
@GetMapping(value = "/client/test")
public List getArticles(@RegisteredOAuth2AuthorizedClient("messaging-client-pkce") OAuth2AuthorizedClient authorizedClient) {
return this.webClient
.get()
.uri("http://127.0.0.1:8090/resource/article")
.attributes(oauth2AuthorizedClient(authorizedClient))
.retrieve()
.bodyToMono(List.class)
.block();
}
}
Resource Server ๐
In this section, we will use Spring Security to build a resource server.
Maven
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<version>2.6.7</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
<version>2.6.7</version>
</dependency>
Configuration
Configure the resource server service port 8070 through application.yml
, and specify the authorization server jwk uri, which is used to obtain the public key information and verify the token:
server:
port: 8090
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: http://127.0.0.1:8080/oauth2/jwks
Next configure the Spring Security configuration class to specify access to protected endpoints:
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain defaultSecurityFilter(HttpSecurity http) throws Exception {
http.requestMatchers()
.antMatchers("/resource/article")
.and()
.authorizeHttpRequests((authorize) -> authorize
.antMatchers("/resource/article")
.hasAuthority("SCOPE_message.read")
.mvcMatchers()
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt);
return http.build();
}
}
In the above configuration class, it is specified that /resource/article must have the message.read permission to access, and the resource service is configured to use JWT authentication.
Afterwards we will create the Controller class, creating protected test endpoints:
@RestController
public class ArticleRestController {
@GetMapping("/resource/article")
public List<String> article() {
return Arrays.asList("article1", "article2", "article3");
}
}
Access Resource List
After starting all the services, enter http://127.0.0.1:8070/client/test in the browser, after passing the authorization server authentication, you will see the following output information on the page:
["article1","article2","article3"]
Conclusion
Confidential client-side PKCE has become the default behavior in the current version of Spring Security. PKCE can also be used in secure client authorization code mode.
The source code used in this article is available on GitHub.
You might want to read on to the next one:
Thanks for reading! ๐
Top comments (0)