Chapter 2: Authentication and Authorization with Keycloak
Introduction
Modern applications require robust authentication and authorization mechanisms that can scale across multiple services and support various login methods. Keycloak provides a comprehensive identity and access management solution that integrates seamlessly with Spring Boot applications.
This chapter demonstrates how to set up Keycloak, configure it for multi-tenant applications, and integrate it with Spring Boot using OAuth 2.0 and OpenID Connect.
Learning Objectives
By the end of this chapter, you will:
- Set up Keycloak with PostgreSQL backend
- Configure realms, clients, and identity providers
- Integrate Spring Boot with Keycloak for authentication
- Implement social login (Google, GitHub)
- Build multi-tenant authentication patterns
- Create custom MCP servers for Keycloak management
Keycloak Fundamentals
Keycloak is an open-source identity and access management solution that provides:
- Single Sign-On (SSO): Users authenticate once across multiple applications
- Identity Brokering: Integration with external identity providers
- Social Login: Support for Google, GitHub, Facebook, etc.
- Multi-tenancy: Separate realms for different organizations
- Standards-based: OAuth 2.0, OpenID Connect, SAML 2.0
Core Concepts
┌─────────────────────────────────────────────────────────┐
│ Keycloak Server │
├─────────────────────────────────────────────────────────┤
│ Realm: "master" │ Realm: "company-a" │
│ ├─ Users: admin │ ├─ Users: john, jane │
│ ├─ Clients: admin-cli │ ├─ Clients: webapp, api │
│ └─ Roles: admin │ ├─ Roles: user, manager │
│ │ └─ Identity Providers: │
│ Realm: "company-b" │ ├─ Google │
│ ├─ Users: alice, bob │ └─ GitHub │
│ ├─ Clients: mobile-app │ │
│ └─ Roles: viewer, editor │ │
└─────────────────────────────────────────────────────────┘
Setting Up Keycloak
Docker Compose Configuration
# docker-compose.yml
version: '3.8'
services:
postgres:
image: postgres:15
container_name: keycloak-postgres
environment:
POSTGRES_DB: keycloak
POSTGRES_USER: keycloak
POSTGRES_PASSWORD: keycloak123
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- keycloak-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U keycloak"]
interval: 10s
timeout: 5s
retries: 5
keycloak:
image: quay.io/keycloak/keycloak:23.0
container_name: keycloak
environment:
KC_DB: postgres
KC_DB_URL: jdbc:postgresql://postgres:5432/keycloak
KC_DB_USERNAME: keycloak
KC_DB_PASSWORD: keycloak123
KC_HOSTNAME: localhost
KC_HOSTNAME_PORT: 8081
KC_HOSTNAME_STRICT: false
KC_HOSTNAME_STRICT_HTTPS: false
KC_LOG_LEVEL: info
KC_METRICS_ENABLED: true
KC_HEALTH_ENABLED: true
KEYCLOAK_ADMIN: admin
KEYCLOAK_ADMIN_PASSWORD: admin123
command: start-dev
ports:
- "8081:8080"
depends_on:
postgres:
condition: service_healthy
networks:
- keycloak-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 30s
timeout: 10s
retries: 5
volumes:
postgres_data:
networks:
keycloak-network:
driver: bridge
Starting Keycloak
# Start the services
docker-compose up -d
# Check status
docker-compose ps
# View logs
docker-compose logs keycloak
# Access Keycloak Admin Console
# http://localhost:8081
# Username: admin
# Password: admin123
Creating a Test Realm
Realm Configuration via API
#!/bin/bash
# create-test-realm.sh
# Get admin token
TOKEN=$(curl -s -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli&username=admin&password=admin123&grant_type=password" | \
jq -r '.access_token')
# Create test realm
curl -X POST "http://localhost:8081/admin/realms" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"realm": "test",
"displayName": "Test Application Realm",
"enabled": true,
"registrationAllowed": true,
"loginWithEmailAllowed": true,
"resetPasswordAllowed": true,
"rememberMe": true,
"bruteForceProtected": true,
"sslRequired": "external"
}'
echo "Test realm created successfully!"
Creating Sample Users
#!/bin/bash
# create-sample-users.sh
TOKEN=$(curl -s -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli&username=admin&password=admin123&grant_type=password" | \
jq -r '.access_token')
# Create users
USERS=(
'{"username":"testuser1","email":"test1@example.com","firstName":"Test","lastName":"User One","enabled":true,"emailVerified":true}'
'{"username":"testuser2","email":"test2@example.com","firstName":"Test","lastName":"User Two","enabled":true,"emailVerified":true}'
'{"username":"admin","email":"admin@example.com","firstName":"Admin","lastName":"User","enabled":true,"emailVerified":true}'
)
for user in "${USERS[@]}"; do
curl -X POST "http://localhost:8081/admin/realms/test/users" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$user"
done
# Set passwords
USER_IDS=(
$(curl -s "http://localhost:8081/admin/realms/test/users?username=testuser1" -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')
$(curl -s "http://localhost:8081/admin/realms/test/users?username=testuser2" -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')
$(curl -s "http://localhost:8081/admin/realms/test/users?username=admin" -H "Authorization: Bearer $TOKEN" | jq -r '.[0].id')
)
PASSWORDS=("password123" "password456" "admin123")
for i in "${!USER_IDS[@]}"; do
curl -X PUT "http://localhost:8081/admin/realms/test/users/${USER_IDS[$i]}/reset-password" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"type\":\"password\",\"value\":\"${PASSWORDS[$i]}\",\"temporary\":false}"
done
echo "Sample users created with passwords!"
Spring Boot Integration
Dependencies
<dependencies>
<!-- Spring Boot Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- OAuth2 Client -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<!-- OAuth2 Resource Server -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
</dependencies>
Application Configuration
# application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: spring-boot-app
client-secret: spring-boot-secret
scope: openid,profile,email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8081/realms/test
user-name-attribute: preferred_username
resourceserver:
jwt:
issuer-uri: http://localhost:8081/realms/test
jwk-set-uri: http://localhost:8081/realms/test/protocol/openid-connect/certs
# Multi-tenant configuration
app:
keycloak:
server-url: http://localhost:8081
realms:
- name: test
client-id: spring-boot-app
client-secret: spring-boot-secret
- name: company-a
client-id: company-a-app
client-secret: company-a-secret
Security Configuration
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**", "/health/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService())
)
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.logout(logout -> logout
.logoutSuccessUrl("/")
.invalidateHttpSession(true)
.clearAuthentication(true)
);
return http.build();
}
@Bean
public CustomOAuth2UserService customOAuth2UserService() {
return new CustomOAuth2UserService();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter authoritiesConverter =
new JwtGrantedAuthoritiesConverter();
authoritiesConverter.setAuthorityPrefix("ROLE_");
authoritiesConverter.setAuthoritiesClaimName("realm_access.roles");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
}
Custom OAuth2 User Service
@Service
public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository;
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
public CustomOAuth2UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oauth2User = delegate.loadUser(userRequest);
String email = oauth2User.getAttribute("email");
String username = oauth2User.getAttribute("preferred_username");
// Create or update user in local database
User user = userRepository.findByEmail(email)
.orElseGet(() -> createNewUser(oauth2User));
return new CustomOAuth2User(oauth2User, user);
}
private User createNewUser(OAuth2User oauth2User) {
User user = new User();
user.setEmail(oauth2User.getAttribute("email"));
user.setUsername(oauth2User.getAttribute("preferred_username"));
user.setFirstName(oauth2User.getAttribute("given_name"));
user.setLastName(oauth2User.getAttribute("family_name"));
user.setEnabled(true);
user.setCreatedAt(Instant.now());
return userRepository.save(user);
}
}
public class CustomOAuth2User implements OAuth2User {
private final OAuth2User oauth2User;
private final User user;
public CustomOAuth2User(OAuth2User oauth2User, User user) {
this.oauth2User = oauth2User;
this.user = user;
}
@Override
public Map<String, Object> getAttributes() {
return oauth2User.getAttributes();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return oauth2User.getAuthorities();
}
@Override
public String getName() {
return oauth2User.getName();
}
public User getUser() {
return user;
}
}
Social Login Configuration
Google OAuth Setup
#!/bin/bash
# update-google-oauth.sh
if [ $# -ne 2 ]; then
echo "Usage: $0 <GOOGLE_CLIENT_ID> <GOOGLE_CLIENT_SECRET>"
exit 1
fi
GOOGLE_CLIENT_ID="$1"
GOOGLE_CLIENT_SECRET="$2"
TOKEN=$(curl -s -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli&username=admin&password=admin123&grant_type=password" | \
jq -r '.access_token')
# Update Google identity provider
curl -X PUT "http://localhost:8081/admin/realms/test/identity-provider/instances/google" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"alias\": \"google\",
\"displayName\": \"Google\",
\"providerId\": \"google\",
\"enabled\": true,
\"updateProfileFirstLoginMode\": \"on\",
\"trustEmail\": true,
\"storeToken\": false,
\"addReadTokenRoleOnCreate\": false,
\"authenticateByDefault\": false,
\"linkOnly\": false,
\"firstBrokerLoginFlowAlias\": \"first broker login\",
\"config\": {
\"clientId\": \"$GOOGLE_CLIENT_ID\",
\"clientSecret\": \"$GOOGLE_CLIENT_SECRET\",
\"userInfoUrl\": \"https://www.googleapis.com/oauth2/v2/userinfo\",
\"tokenUrl\": \"https://www.googleapis.com/oauth2/v4/token\",
\"authorizationUrl\": \"https://accounts.google.com/o/oauth2/v2/auth\",
\"issuer\": \"https://accounts.google.com\",
\"defaultScope\": \"openid profile email\",
\"useJwksUrl\": \"true\",
\"jwksUrl\": \"https://www.googleapis.com/oauth2/v3/certs\"
}
}"
echo "Google OAuth configuration updated!"
echo "Redirect URI for Google Console: http://localhost:8081/realms/test/broker/google/endpoint"
GitHub OAuth Setup
#!/bin/bash
# update-github-oauth.sh
if [ $# -ne 2 ]; then
echo "Usage: $0 <GITHUB_CLIENT_ID> <GITHUB_CLIENT_SECRET>"
exit 1
fi
GITHUB_CLIENT_ID="$1"
GITHUB_CLIENT_SECRET="$2"
TOKEN=$(curl -s -X POST "http://localhost:8081/realms/master/protocol/openid-connect/token" \
-d "client_id=admin-cli&username=admin&password=admin123&grant_type=password" | \
jq -r '.access_token')
# Create GitHub identity provider
curl -X POST "http://localhost:8081/admin/realms/test/identity-provider/instances" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{
\"alias\": \"github\",
\"displayName\": \"GitHub\",
\"providerId\": \"github\",
\"enabled\": true,
\"updateProfileFirstLoginMode\": \"on\",
\"trustEmail\": true,
\"storeToken\": false,
\"addReadTokenRoleOnCreate\": false,
\"authenticateByDefault\": false,
\"linkOnly\": false,
\"firstBrokerLoginFlowAlias\": \"first broker login\",
\"config\": {
\"clientId\": \"$GITHUB_CLIENT_ID\",
\"clientSecret\": \"$GITHUB_CLIENT_SECRET\",
\"userInfoUrl\": \"https://api.github.com/user\",
\"tokenUrl\": \"https://github.com/login/oauth/access_token\",
\"authorizationUrl\": \"https://github.com/login/oauth/authorize\",
\"defaultScope\": \"user:email\"
}
}"
echo "GitHub OAuth configuration created!"
echo "Callback URL for GitHub App: http://localhost:8081/realms/test/broker/github/endpoint"
Multi-Tenant Authentication
Tenant-Aware Security Configuration
@Configuration
public class MultiTenantSecurityConfig {
@Bean
public TenantResolver tenantResolver() {
return new HeaderTenantResolver(); // or SubdomainTenantResolver
}
@Bean
public MultiTenantJwtDecoder jwtDecoder(TenantResolver tenantResolver) {
return new MultiTenantJwtDecoder(tenantResolver);
}
}
@Component
public class HeaderTenantResolver implements TenantResolver {
@Override
public String resolveTenant(HttpServletRequest request) {
String tenant = request.getHeader("X-Tenant-ID");
if (tenant == null) {
tenant = extractTenantFromPath(request.getRequestURI());
}
return tenant != null ? tenant : "default";
}
private String extractTenantFromPath(String path) {
// Extract tenant from path like /tenant/{tenantId}/api/...
if (path.startsWith("/tenant/")) {
String[] parts = path.split("/");
return parts.length > 2 ? parts[2] : null;
}
return null;
}
}
public class MultiTenantJwtDecoder implements JwtDecoder {
private final TenantResolver tenantResolver;
private final Map<String, JwtDecoder> jwtDecoders = new ConcurrentHashMap<>();
public MultiTenantJwtDecoder(TenantResolver tenantResolver) {
this.tenantResolver = tenantResolver;
}
@Override
public Jwt decode(String token) throws JwtException {
String tenant = getCurrentTenant();
JwtDecoder decoder = jwtDecoders.computeIfAbsent(tenant, this::createJwtDecoder);
return decoder.decode(token);
}
private String getCurrentTenant() {
HttpServletRequest request = getCurrentRequest();
return tenantResolver.resolveTenant(request);
}
private JwtDecoder createJwtDecoder(String tenant) {
String issuerUri = String.format("http://localhost:8081/realms/%s", tenant);
return JwtDecoders.fromIssuerLocation(issuerUri);
}
private HttpServletRequest getCurrentRequest() {
return ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes())
.getRequest();
}
}
Tenant-Aware Controllers
@RestController
@RequestMapping("/api/v1")
public class UserController {
private final UserService userService;
private final TenantContext tenantContext;
@GetMapping("/users")
@PreAuthorize("hasRole('USER')")
public ResponseEntity<List<UserDto>> getUsers() {
String tenant = tenantContext.getCurrentTenant();
List<User> users = userService.findByTenant(tenant);
List<UserDto> userDtos = users.stream()
.map(this::convertToDto)
.toList();
return ResponseEntity.ok(userDtos);
}
@GetMapping("/users/{id}")
@PreAuthorize("hasRole('USER') and @userService.hasAccess(#id, authentication.name)")
public ResponseEntity<UserDto> getUser(@PathVariable Long id) {
User user = userService.findByIdAndTenant(id, tenantContext.getCurrentTenant());
return ResponseEntity.ok(convertToDto(user));
}
private UserDto convertToDto(User user) {
return new UserDto(
user.getId(),
user.getUsername(),
user.getEmail(),
user.getFirstName(),
user.getLastName(),
user.isEnabled()
);
}
}
Building an MCP Server for Keycloak
Keycloak MCP Server
#!/usr/bin/env python3
"""
Keycloak MCP Server - Provides access to Keycloak Admin API
"""
import asyncio
import json
from typing import Any, Dict, List, Optional
import httpx
from mcp.server import Server
from mcp.server.models import InitializationOptions
import mcp.server.stdio
import mcp.types as types
from pydantic import BaseModel
class KeycloakConfig(BaseModel):
base_url: str = "http://localhost:8081"
admin_username: str = "admin"
admin_password: str = "admin123"
realm: str = "master"
class KeycloakMCPServer:
def __init__(self):
self.server = Server("keycloak-mcp")
self.config = KeycloakConfig()
self.access_token: Optional[str] = None
self.setup_handlers()
async def get_admin_token(self) -> str:
"""Get admin access token from Keycloak"""
if self.access_token:
return self.access_token
async with httpx.AsyncClient() as client:
response = await client.post(
f"{self.config.base_url}/realms/{self.config.realm}/protocol/openid-connect/token",
data={
"client_id": "admin-cli",
"username": self.config.admin_username,
"password": self.config.admin_password,
"grant_type": "password",
},
headers={"Content-Type": "application/x-www-form-urlencoded"}
)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data["access_token"]
return self.access_token
async def keycloak_request(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
"""Make authenticated request to Keycloak Admin API"""
token = await self.get_admin_token()
headers = {"Authorization": f"Bearer {token}"}
if "headers" in kwargs:
headers.update(kwargs["headers"])
kwargs["headers"] = headers
url = f"{self.config.base_url}/admin/realms/{path}"
async with httpx.AsyncClient() as client:
response = await client.request(method, url, **kwargs)
response.raise_for_status()
if response.headers.get("content-type", "").startswith("application/json"):
return response.json()
return {"status": "success", "content": response.text}
def setup_handlers(self):
@self.server.list_resources()
async def handle_list_resources() -> List[types.Resource]:
"""List available Keycloak resources"""
return [
types.Resource(
uri="keycloak://realms",
name="Keycloak Realms",
description="List all realms",
mimeType="application/json",
),
types.Resource(
uri="keycloak://users",
name="Keycloak Users",
description="List users in master realm",
mimeType="application/json",
),
types.Resource(
uri="keycloak://clients",
name="Keycloak Clients",
description="List clients in master realm",
mimeType="application/json",
),
]
@self.server.read_resource()
async def handle_read_resource(uri: str) -> str:
"""Read specific Keycloak resource"""
if uri == "keycloak://realms":
realms = await self.keycloak_request("GET", "")
return json.dumps(realms, indent=2)
elif uri == "keycloak://users":
users = await self.keycloak_request("GET", f"{self.config.realm}/users")
return json.dumps(users, indent=2)
elif uri == "keycloak://clients":
clients = await self.keycloak_request("GET", f"{self.config.realm}/clients")
return json.dumps(clients, indent=2)
else:
raise ValueError(f"Unknown resource URI: {uri}")
@self.server.list_tools()
async def handle_list_tools() -> List[types.Tool]:
"""List available Keycloak tools"""
return [
types.Tool(
name="keycloak_create_user",
description="Create a new user in Keycloak",
inputSchema={
"type": "object",
"properties": {
"username": {"type": "string", "description": "Username"},
"email": {"type": "string", "description": "Email address"},
"firstName": {"type": "string", "description": "First name"},
"lastName": {"type": "string", "description": "Last name"},
"password": {"type": "string", "description": "Temporary password"},
"realm": {"type": "string", "description": "Realm name", "default": "master"},
},
"required": ["username", "email"],
},
),
types.Tool(
name="keycloak_get_user",
description="Get user details by username",
inputSchema={
"type": "object",
"properties": {
"username": {"type": "string", "description": "Username to search for"},
"realm": {"type": "string", "description": "Realm name", "default": "master"},
},
"required": ["username"],
},
),
]
@self.server.call_tool()
async def handle_call_tool(name: str, arguments: Dict[str, Any]) -> List[types.TextContent]:
"""Handle tool calls"""
try:
if name == "keycloak_create_user":
realm = arguments.get("realm", "master")
user_data = {
"username": arguments["username"],
"email": arguments["email"],
"enabled": True,
"emailVerified": True,
}
if "firstName" in arguments:
user_data["firstName"] = arguments["firstName"]
if "lastName" in arguments:
user_data["lastName"] = arguments["lastName"]
# Create user
await self.keycloak_request("POST", f"{realm}/users", json=user_data)
# Set password if provided
if "password" in arguments:
users = await self.keycloak_request("GET", f"{realm}/users?username={arguments['username']}")
if users:
user_id = users[0]["id"]
password_data = {
"type": "password",
"value": arguments["password"],
"temporary": False,
}
await self.keycloak_request("PUT", f"{realm}/users/{user_id}/reset-password", json=password_data)
return [types.TextContent(type="text", text=f"User {arguments['username']} created successfully")]
elif name == "keycloak_get_user":
realm = arguments.get("realm", "master")
users = await self.keycloak_request("GET", f"{realm}/users?username={arguments['username']}")
return [types.TextContent(type="text", text=json.dumps(users, indent=2))]
else:
raise ValueError(f"Unknown tool: {name}")
except Exception as e:
return [types.TextContent(type="text", text=f"Error: {str(e)}")]
async def run(self):
"""Run the MCP server"""
async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
await self.server.run(
read_stream,
write_stream,
InitializationOptions(
server_name="keycloak-mcp",
server_version="0.1.0",
capabilities=self.server.get_capabilities(),
),
)
async def main():
server = KeycloakMCPServer()
await server.run()
if __name__ == "__main__":
asyncio.run(main())
Testing and Validation
Integration Tests
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
"spring.security.oauth2.client.registration.keycloak.client-id=test-client",
"spring.security.oauth2.client.registration.keycloak.client-secret=test-secret"
})
class KeycloakIntegrationTest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private WebTestClient webTestClient;
@Test
void shouldRedirectToKeycloakForAuthentication() {
ResponseEntity<String> response = restTemplate.getForEntity("/secure", String.class);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FOUND);
assertThat(response.getHeaders().getLocation().toString())
.contains("localhost:8081/realms/test/protocol/openid-connect/auth");
}
@Test
@WithMockOAuth2User(authorities = "ROLE_USER", attributes = @WithMockOAuth2User.OAuth2Attribute(name = "preferred_username", value = "testuser"))
void shouldAllowAccessWithValidToken() {
webTestClient
.get()
.uri("/api/v1/users")
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$").isArray();
}
}
Load Testing
@Component
public class KeycloakLoadTest {
private final WebClient webClient;
private final String keycloakUrl = "http://localhost:8081";
public void performConcurrentLogin(int numberOfUsers) {
List<CompletableFuture<Void>> futures = IntStream.range(0, numberOfUsers)
.mapToObj(i -> CompletableFuture.runAsync(() -> {
try {
String username = "testuser" + i;
String password = "password123";
// Get token
String token = getAccessToken(username, password);
// Make authenticated request
String response = webClient
.get()
.uri("/api/v1/profile")
.header("Authorization", "Bearer " + token)
.retrieve()
.bodyToMono(String.class)
.block();
System.out.println("User " + username + " authenticated successfully");
} catch (Exception e) {
System.err.println("Authentication failed for user " + i + ": " + e.getMessage());
}
}))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
private String getAccessToken(String username, String password) {
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", "spring-boot-app");
formData.add("client_secret", "spring-boot-secret");
formData.add("grant_type", "password");
formData.add("username", username);
formData.add("password", password);
return webClient
.post()
.uri(keycloakUrl + "/realms/test/protocol/openid-connect/token")
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body(BodyInserters.fromFormData(formData))
.retrieve()
.bodyToMono(TokenResponse.class)
.map(TokenResponse::getAccessToken)
.block();
}
}
record TokenResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("token_type") String tokenType,
@JsonProperty("expires_in") int expiresIn
) {}
Exercise: Complete Authentication System
Task
Build a complete authentication system that:
- Supports multiple tenants
- Integrates with Google and GitHub
- Provides user management APIs
- Includes comprehensive testing
Implementation Steps
- Set up Keycloak with multiple realms
- Configure social identity providers
- Build Spring Boot application with multi-tenant support
- Implement user management endpoints
- Add comprehensive tests
- Create monitoring and metrics
Summary
Keycloak provides a robust foundation for enterprise authentication and authorization. Key takeaways:
- Realm-based multi-tenancy enables isolated user management
- Social login integration reduces user friction
- Spring Security integration provides seamless OAuth2/OIDC support
- Custom MCP servers enable AI-assisted Keycloak management
- Comprehensive testing ensures reliability at scale
In the next chapter, we’ll explore multi-tenant architecture patterns and how to structure applications for scalable multi-tenancy.