OAuth2 with Spring — Part 5: Securing Your Spring Boot Application with PKCE for Enhanced Security
Disclaimer: This article will be very technical and requires clear understanding of the previous articles of this series, specially, Part 1 and Part 3.
Authorization Code Flow with Proof Key for Code Exchange (PKCE) is used for applications, which can’t store a client secret. Such applications are —
- Native apps — Mobile applications that can be decompiled and client credentials can be retrieved.
- Single-page apps — the entire source code is available in the browser. Hence, securely storing the client secret is not possible.
How it works:
Hopefully, the diagram is already self-explanatory. Hence, I will directly hop into the demonstration.
For this demo, we have 3 servers.
- Authorization Server: Running on port 9001.
- Resource Server: Running on port 8090.
- Social Login client (BFF): Running on port 8080.
Let’s walk through the code.
Authorization Server
i. pom.xml
There is not much change to explain in pom.xml for authorization server
ii. application.yml
application.yml file has lots of changes, specially removals of client registration. The current version of application.yml is very short and looks like below.
server:
port: 9001
logging:
level:
org:
springframework:
security: trace
iii. SecurityConfig class
All the security configurations and client and user details registration, JWT token decoding are placed in this class
@Configuration
public class SecurityConfig {
// This first SecurityFilterChain Bean is only specific to authorization server specific configurations
// More on this can be found in this stackoverflow question answers:
// https://stackoverflow.com/questions/69126874/why-two-formlogin-configured-in-spring-authorization-server-sample-code
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http);
http.getConfigurer(OAuth2AuthorizationServerConfigurer.class).oidc(withDefaults());
return http
.exceptionHandling(e -> e
.authenticationEntryPoint(new LoginUrlAuthenticationEntryPoint("/login")))
.oauth2ResourceServer(httpSecurityOAuth2ResourceServerConfigurer ->
httpSecurityOAuth2ResourceServerConfigurer.jwt(withDefaults())
)
.build();
}
// This second SecurityFilterChain bean is responsible for any other security configurations
@Bean
@Order(2)
public SecurityFilterChain clientAppSecurityFilterChain(HttpSecurity http) throws Exception {
return http
.formLogin(withDefaults())
.authorizeHttpRequests(authorize ->authorize.anyRequest().authenticated())
.build();
}
// In-memory user registration
@Bean
public UserDetailsService userDetailsService() {
var user1 = User.withUsername("user")
.password("{noop}secret")
.authorities("read")
.build();
return new InMemoryUserDetailsManager(user1);
}
// In-memory authorization server client registration
@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId("oidc-client")
.clientId("oidc-client")
.clientSecret("{noop}secret")
.scope("read")
.scope(OidcScopes.OPENID)
.scope(OidcScopes.PROFILE)
.scope("write")
.redirectUri("http://127.0.0.1:8080/login/oauth2/code/oidc-client")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.clientSettings(clientSettings())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
/**
* Creating this bean initialized the following endpoints:
* /oauth2/authorize
* /oauth2/device_authorization
* /oauth2/token
* /oauth2/jwks
* /oauth2/revoke
* /oauth2/introspect
* /connect/register
* /userinfo
* /connect/logout
*
* For java based client registration configuration, it is very important to initialize this bean
*/
@Bean
public AuthorizationServerSettings authorizationServerSettings() {
return AuthorizationServerSettings.builder().build();
}
@Bean
ClientSettings clientSettings() {
return ClientSettings.builder()
.requireAuthorizationConsent(true) // Display post-login authorization consent screen
.requireProofKey(true) // flag to enable Proof Key for Code Exchange (PKCE)
.build();
}
@Bean
public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) {
return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource);
}
@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsa();
JWKSet jwkSet = new JWKSet(rsaKey);
return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet);
}
public static RSAKey generateRsa() {
KeyPair keyPair = generateRsaKey();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey).privateKey(privateKey).keyID(UUID.randomUUID().toString()).build();
}
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;
}
}
Now start the authorization server.
Resource Server
i. pom.xml
The pom.xml must contain the spring-boot-starter-oauth2-resource-server dependency along with other dependencies.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
From configuration perspective, Resource Server contains only application.yml
ii. application.yml
We need to mention the token issuer uri in the application.yml file.
server:
port: 8090
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:9001
iii. SecurityConfig
The purpose of SecurityConfig class is to verify the access token from the authorization server and adding additional security configurations if needed for the endpoints defined in the resource server itself.
social-login-client
i. pom.xml
The pom.xml must contain spring-boot-starter-oauth2-client dependency along with other dependencies. Additionally, since we have used webClient, hence we added spring-boot-starter-webflux dependency.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
ii. application.yml
The application.yml file contains configuration of the client itself and urls to communicate for authorization process. The complete code for my oidc-client registration is given below.
spring:
security:
oauth2:
client:
registration:
# Client registration starts here
oidc-client:
# Our oidc-client needs a provider. The provider information has been registered
# at the bottom of this configuration
provider: spring
# The following client-id and client-secret will be sent to the authorization server
# We don't need to mention the client_credentials in the grant type here.
# Note that, here the client-secret must not contain {noop} or any other encoding type mentioned.
client-id: oidc-client
client-secret: secret
# Our authorization grant type is authorization_code
authorization-grant-type: authorization_code
# The following redirect URL is the redirect URL definition of our client Server application.
# It is generally the current application host address. The authorization server's redirect URL
# definition means that this URL will be triggered when auth server redirects data to here.
redirect-uri: http://127.0.0.1:8080/login/oauth2/code/oidc-client
# Scopes that will be displayed for requesting in the consent page.
# Authorization server must have equal or more scopes than these in number
scope:
- openid
- profile
- read
- write
# This client name will display in the login screen as social login type
client-name: oidc-client
# As mentioned above about provider, here we register the provider details
# for any unknown provider with their issuer URI
provider:
spring:
issuer-uri: http://localhost:9001
iii. SecurityConfig
We need to write the SecurityConfig class to enable PKCE to generate code resolver and code challenge, put the logics of OAuth2Login endpoint and secure the endpoints defined in the client application.
The complete code is given below.
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, ClientRegistrationRepository clientRegistrationRepository) throws Exception {
String base_uri = OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI;
DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, base_uri);
// Responsible for enabling PKCE, to generate code verifier, code challenge
resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());
http
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/").permitAll()
.anyRequest().authenticated())
.oauth2Login(oauth2Login -> {
oauth2Login.loginPage("/oauth2/authorization/oidc-client");
oauth2Login.authorizationEndpoint(authorizationEndpointConfig ->
authorizationEndpointConfig.authorizationRequestResolver(resolver)
);
})
.oauth2Client(withDefaults());
return http.build();
}
}
iv. WebClientConfig
To allow webClient to work with the OAuth2 seemlessly, we need to define WebClientConfig and add couple more beans.
@Configuration
public class WebClientConfig {
@Bean
public HelloClient helloClient(OAuth2AuthorizedClientManager authorizedClientManager) throws Exception {
return httpServiceProxyFactory(authorizedClientManager).createClient(HelloClient.class);
}
private HttpServiceProxyFactory httpServiceProxyFactory(OAuth2AuthorizedClientManager authorizedClientManager) {
ServletOAuth2AuthorizedClientExchangeFilterFunction oauth2Client =
new ServletOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
oauth2Client.setDefaultOAuth2AuthorizedClient(true);
WebClient webClient = WebClient.builder()
.apply(oauth2Client.oauth2Configuration())
.build();
WebClientAdapter client = WebClientAdapter.forClient(webClient);
return HttpServiceProxyFactory.builder(client).build();
}
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrationRepository,
OAuth2AuthorizedClientRepository authorizedClientRepository) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken()
.build();
DefaultOAuth2AuthorizedClientManager authorizedClientManager =
new DefaultOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
}
If you notice the bean for HelloClient, this way we can define more clients to make the Business Service (resource server) calling process easier.
v. Calling the Resource Server
We need to define a
@HttpExchange("http://localhost:8090")
public interface HelloClient {
@GetExchange("/")
String getHello();
}
From the controller, let’s now call the getHello() method.
@RestController
@RequestMapping("/")
public class AppController {
@Autowired
private AppService appService;
private final HelloClient helloClient;
public AppController(HelloClient helloClient) {
this.helloClient = helloClient;
}
@GetMapping("/")
public ResponseEntity<String> getPublicData() {
return ResponseEntity.ok("Public data");
}
@GetMapping("/private-data")
public ResponseEntity<String> getPrivateData() {
return ResponseEntity.ok(appService.getJwtToken());
}
@GetMapping("/hello")
public ResponseEntity<String> sayHello () {
return ResponseEntity.ok(helloClient.getHello());
}
}
Time to test in Browser
Before we begin, let’s change the logging configuration in the application.yml to see the debug logs of all 3 servers.
logging:
level:
org.springframework.boot: error
org.springframework.security: debug
org.springframework.security.web: debug
org.apache.catalina: error
com.mainul35:
- info
- trace
- debug
- error
- warn
Now, let’s hit (1) http://localhost:8080/hello in the browser. It will take us to the authorization server’s login endpoint, in our case, http://localhost:9001/login. But before that, it will do several redirections. Let’s see it through the browser network tab first.
If we notice at the above network response, we can see, when we requested for the /hello endpoint, it redirected us first to the (2) http://localhost:8080/oauth2/authorization/oidc-client endpoint.
Then, from the client authorization endpoint, we were again redirected to the following endpoint of the authorization server. (3)
When the authorization server sees, the request was not authorized yet, it again redirects to the login endpoint of the authorization server. (4)
Now, let’s provide the correct user credentials, and observe.
It first submits the form to the login endpoint with POST method. Which we can see as another redirection in the browser network tab.
Next, if the authentication succeeds, we are then taken to the authorization consent screen. The URL is as below. (5)
Note, If we authorize the application with not all consents, it may redirect us to the same consent screen. Hence, to go forward after providing some consents, we may click on cancel, or provide all consents.
Now, let’s provide some consent and go forward. For my case, I will provide all the consents and submit.
Long story short, after successful login, it redirects us to the client application’s /login/oauth2/code/oidc-client endpoint with authorization code parameter. With this code, we can then request to /oauth2/token endpoint to fetch the access token.
Now we can request to the /hello endpoint again and see it working.
Why not there is any request to the /oauth/token endpoint after receiving code?
From the following draft of OAuth2 for Browser based Apps provided in the IETF website, we can see, for PKCE, it mush not issue an access token in the authorization server.
Here is the link for the draft: draft-ietf-oauth-browser-based-apps-10
If we still want to fetch the token, in my client application, I provided an endpoint 127.0.0.1:8080/private-data, by which we can fetch the token.
The complete source code can be found here: mainul35/authorization-server-demo at authorization-server-demo/social-login-with-oidc-pkce (github.com)
Special thanks to Willy De Keyser