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:

  1. Supports multiple tenants
  2. Integrates with Google and GitHub
  3. Provides user management APIs
  4. Includes comprehensive testing

Implementation Steps

  1. Set up Keycloak with multiple realms
  2. Configure social identity providers
  3. Build Spring Boot application with multi-tenant support
  4. Implement user management endpoints
  5. Add comprehensive tests
  6. Create monitoring and metrics

Summary

Keycloak provides a robust foundation for enterprise authentication and authorization. Key takeaways:

  1. Realm-based multi-tenancy enables isolated user management
  2. Social login integration reduces user friction
  3. Spring Security integration provides seamless OAuth2/OIDC support
  4. Custom MCP servers enable AI-assisted Keycloak management
  5. 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.

Further Reading