Secure Microservices with Keycloak Client Credentials Flow
1. Introduction
In this tutorial, we’ll explore the process of establishing secure authentication between Spring Boot microservices by using Keycloak OAuth2.0 Client Credentials flow. The Client Credentials Flow is a server-to-server authentication mechanism, ideal for scenarios where one application needs to send requests to another without user involvement, such as between two microservices.
1.1 How Client Credentials Flow Works:
- To initiate the flow, the client application sends an HTTP POST request to the token endpoint of the authorization server.
- This request includes parameters such as
grant_type
,client_id
,client_secret
, andscope
. - Upon successful validation, the server promptly responds with an access token.
- With this access token in hand, the client can now seamlessly send requests to the resource server.
2. Setting Up Keycloak
To get started, first, we need to install Keycloak on our system. In this example, we are using Keycloak v22.
Begin by downloading the latest version of Keycloak from the official website. Once the download is complete, unzip the Keycloak archive. Next, navigate to the bin directory and run the following command:
./kc.sh start-dev
After starting the Keycloak instance, open your web browser and navigate to http://localhost:8080/
.
Under the Administration Console section, create an admin account with the username ‘admin’ and the password ‘admin’.
2.1 Creating a Realm
Realms in Keycloak define a space for users, roles, and applications.
- In the top left corner, click on the dropdown next to
Master
and then click onCreate Realm
. - For this tutorial, we named the realm as
FullStackApp
. - Click on the
Create
button.
2.2 Creating a Client
Once we have our realm, let’s start creating Keycloak clients.
The backend client is usually configured as “bearer-only.” It doesn’t initiate authentication flows but expects every incoming request to present a valid bearer token (JWT). This token is then validated to ensure that the request is both authenticated and authorized.
- Navigate to the desired realm
FullStackApp
- Go to the
clients
section and click onCreate client
. - Choose
OpenID Connect
the client type. - Enter a unique
Client ID
, we named itfullstack-springboot-client
. This will be used in the spring-boot application. - Turn On the client authentication. When it is on, it is set to confidential access type, client secret will be generated.
- Turn
On
theAuthorization
to enable fine-grained authorization support. It means the client can use OAuth 2.0 scopes to request specific permissions. - Select the
Service accounts roles
the Authentication flow. - Click on the
Create
button.
3. Secure Spring Boot Microservices
In this section, we will configure the order-service
to communicate with the product-service
using Feign client, and we’re also integrating Keycloak for authentication using Feign Configuration.
3.1 Update application.properties with Keycloak Configurations:
In the order-service
application.properties
file, we have to provide the Keycloak configurations. These configurations are essential for the order microservice to authenticate and obtain access tokens from the Keycloak server.
keycloak.client-id=fullstack-springboot-client
keycloak.client-secret=[CLIENT_SECRET]
keycloak.token-uri=http://localhost:8080/realms/FullStackApp/protocol/openid-connect/token
3.2 Modify the Feign Client to Include an OAuth2 Interceptor:
Create a FeignConfig
class that will be used to create a Feign interceptor. This interceptor will add an OAuth2 bearer token to your requests, ensuring secure communication with the product microservice. The @Configuration
annotation marks this class as a configuration class.
@Configuration
public class FeignConfig {
@Value("${keycloak.auth-server-url}")
private String authServerUrl;
@Value("${keycloak.realm}")
private String realm;
@Value("${keycloak.resource}")
private String clientId;
@Value("${keycloak.credentials.secret}")
private String clientSecret;
@Bean
public RequestInterceptor requestTokenBearerInterceptor() {
return requestTemplate -> {
String token = obtainAccessToken();
requestTemplate.header(HttpHeaders.AUTHORIZATION, "Bearer " + token);
};
}
private String obtainAccessToken() {
RestTemplate restTemplate = new RestTemplate();
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("grant_type", "client_credentials");
formData.add("client_id", clientId);
formData.add("client_secret", clientSecret);
ResponseEntity<JsonNode> response = restTemplate.postForEntity(tokenUri, formData, JsonNode.class);
JsonNode responseBody = response.getBody();
return responseBody != null ? responseBody.get("access_token").asText() : null;
}
}
3.3 Update the ProductClient Interface:
Now, let’s update the ProductClient
interface to incorporate the new authentication configuration:
@FeignClient(name = "${product-service.name}", url = "${products.url}", configuration = FeignConfig.class)
public interface ProductClient {
@GetMapping("/{id}")
ResponseEntity<Map<String, Object>> getProductById(@PathVariable("id") Long id);
}
With these configurations, the order microservice is now able to engage in secure communication with the product microservice through the Feign client. This configuration ensures that the order service seamlessly acquires access tokens from Keycloak, guaranteeing that authentication and authorization are maintained throughout the entire interaction.
4. Product Service Security Configuration
Now, we need to configure the security control for the product service. We want to restrict access to the path /api/products/{id}
so that only authenticated and authorized entities, whether they are valid services with valid tokens, can access it.
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/products/{id}", "GET"))
.authenticated()
.requestMatchers(new AntPathRequestMatcher("/api/products/**"))
.hasAnyRole("developer", "admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter)))
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
5. Testing using Postman
Firstly, start the product and order service, and add a new product to the system. Next, create an order associated with the product created.
Attempt to access the order’s details.
Now, let’s intentionally attempt to access these resources with an incorrect client secret and restart the order service.
As expected, this results in a 401 Unauthorized response, demonstrating that the security configurations are correctly preventing unauthorized requests.
However, it’s worth noting that our current configuration doesn’t differentiate between services. This means any microservice with a valid access token can access the product details. In real-world scenarios with diverse microservices accessing multiple APIs, this can pose challenges.
To achieve a more granular level of access control, we can implement authority checks based on OAuth2 scopes, allowing for more fine-tuned security measures.
6. Client Scopes
Scopes are primarily used in the context of OAuth 2.0 and OpenID Connect (OIDC). Scopes define the specific access rights that a client application requests from the authorization server (Keycloak) on behalf of the user. They determine what data or actions the client application is allowed to access.
The client application (e.g. order microservices) needs to be aware of the scope name it requires, and it should include that scope when requesting an access token.
The authorization server verifies whether the client is allowed the requested scope. If granted, the server includes the requested scope in the issued access token. This means the access token will carry the information about the permissions granted to the client, and the resource server (e.g. product microservices) can use this information to make access control decisions.
6.1. Defining Scopes in Keycloak
Begin by navigating to the Client scopes
section within Keycloak. Here, we can create a new scope, let’s call it view-products
.
Now, go to the Clients
section and select the fullstack-springboot-client
. In the Client Scopes
tab, utilize the Add client scope
option to associate the view-products
scope we’ve just defined.
6.2 Update Product Service Security Configuration
Update the product service security configuration to recognize and validate the new scope. This ensures that only authorized requests can access specific resources.
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/products/{id}", "GET"))
.hasAuthority("SCOPE_view-products")
.requestMatchers(new AntPathRequestMatcher("/api/products/**"))
.hasAnyRole("developer", "admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter)))
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
6.3 Verify using Postman
When attempting to retrieve order details via the API, we should expect to receive an Forbidden
error. This is because the required scope is missing.
6.4 Integrate Scope in Feign Config
To complete the scope-based access check, it’s necessary to update the FeignConfig
to include the newly defined scope.
@Value("${keycloak.scope}")
private String scope;
...
private String obtainAccessToken() {
RestTemplate restTemplate = new RestTemplate();
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData add("grant_type", "client_credentials");
formData.add("client_id", clientId);
formData.add("client_secret", clientSecret);
formData.add("scope", scope);
ResponseEntity<JsonNode> response = restTemplate.postForEntity(tokenUri, formData, JsonNode.class);
JsonNode responseBody = response.getBody();
return responseBody != null ? responseBody.get("access_token").asText() : null;
}
With these adjustments, we should now be able to successfully retrieve order details.
Scopes are a powerful tool for fine-grained access control, ensuring that only authorized entities can access specific resources within the system.
7. Service-to-Service Role-based Authorization
In more complex scenarios, adopting a structured approach involves the use of service account roles. A service role refers to a specific type of role used for fine-grained authorization and access control within the Keycloak. Service roles are typically used for authorization within the application.
7.1. Create a New Client in Keycloak
Start by navigating to the FullStackApp
realm in Keycloak and create a new client named order-services
. Activate Client authentication and enable service account roles for this client.
7.2. Define and Assign Roles
Within the Roles
tab, add a new role named product-service-caller
. This role represents the specific authorization needed for the order service to call the product service.
Now, switch to the Service Account Roles
tab. This section enables us to assign roles to the service account associated with the order-services
client. Click on Assign Role
, filter by client roles, select product-service-caller
from the Available Roles list, and then click Assign
.
7.3. Verify Role Assignment Using Postman
For validation, use Postman to request a token for the order-services
client.
Decode the token using a platform like jwt.io. When decoded, the token should display the roles associated with the order service, including the product-service-caller
role we’ve added.
7.4 Update Product Service Security Configuration
Now, modify the application.properties
of the order service to use the client ID and client secret of the new order-services
client. In the FeignConfig
, we can remove the scope, as it’s no longer necessary with this setup.
@Bean
public SecurityFilterChain configure(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(authorizeHttpRequests -> authorizeHttpRequests
.requestMatchers(new AntPathRequestMatcher("/actuator/**")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/api/products/{id}", "GET"))
.hasAuthority("ROLE_product-service-caller")
.requestMatchers(new AntPathRequestMatcher("/api/products/**"))
.hasAnyRole("developer", "admin")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2ResourceServer -> oauth2ResourceServer
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter)))
.sessionManagement(sessionManagement -> sessionManagement.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
7.5. Enhance Role Extraction Methods
In this step, we need to enhance the role extraction process by modifying the extractResourceRoles()
method in JwtAuthConverter
. This change ensures that roles are extracted from all resources, not just those under a specific resource ID.
private Collection<? extends GrantedAuthority> extractResourceRoles(Jwt jwt) {
Map<String, Object> resourceAccess = jwt.getClaim("resource_access");
if (resourceAccess == null) {
return Set.of();
}
Set<GrantedAuthority> authorities = new HashSet<>();
// Iterate over each resource in resource_access and extract its roles
for (Map.Entry<String, Object> entry : resourceAccess.entrySet()) {
Map<String, Object> resource = (Map<String, Object>) entry.getValue();
Collection<String> resourceRoles = (Collection<String>) resource.get("roles");
if (resourceRoles != null) {
authorities.addAll(
resourceRoles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.collect(Collectors.toList())
);
}
}
return authorities;
}
7.6. Verify using Postman
With these configurations in place, we are now ready to test the setup. Attempt to access the order retrieval endpoint.
We’ll notice that the extracted roles now include product-service-caller
, signifying that the order service is equipped with the necessary authorization to interact with the product service.
8. Conclusion
Throughout this post, we’ve explored how the client credentials flow is ideal for server-to-server authentication. We also introduced the concept of scopes and how they define the permissions associated with an access token. For a more structured approach, we demonstrated how to use service account roles. This allows for fine-grained control of permissions and authorization within the microservices architecture. Grab the full source code on GitHub.
Share this content:
Leave a Comment