Keycloak Integration
Overview
This guide covers the integration of Keycloak as the Identity and Access Management (IAM) solution for the Ink platform. Learn how to configure, customize, and manage authentication and authorization using Keycloak.
Target Audience: Security engineers and backend developers
Prerequisites: Understanding of OAuth2, OpenID Connect, and JWT
Estimated Time: 45-60 minutes
Prerequisites
- OAuth 2.0 and OpenID Connect knowledge
- Understanding of JWT tokens
- Docker and Docker Compose installed
- Completed local development setup
Architecture
Installation Steps
1. Start Keycloak with Docker
# filepath: /Users/jetstart/dev/jetrev/ink/docker-compose.yml
# ...existing code...
services:
keycloak:
image: quay.io/keycloak/keycloak:23.0
container_name: ink-keycloak
environment:
- KEYCLOAK_ADMIN=admin
- KEYCLOAK_ADMIN_PASSWORD=admin
- KC_DB=postgres
- KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak
- KC_DB_USERNAME=ink_user
- KC_DB_PASSWORD=ink_password
- KC_HOSTNAME=localhost
- KC_HTTP_ENABLED=true
command: start-dev
ports:
- "8080:8080"
depends_on:
- postgres
networks:
- ink-network
# ...existing code...
2. Initialize Keycloak
# Start Keycloak
docker compose up -d keycloak
# Wait for Keycloak to be ready
until curl -f http://localhost:8080/health/ready; do
echo "Waiting for Keycloak..."
sleep 5
done
# Access admin console
open http://localhost:8080
# Login: admin / admin
3. Create Realm Configuration
Create a realm configuration file:
# filepath: /Users/jetstart/dev/jetrev/ink/config/keycloak/ink-realm.json
{
"realm": "ink",
"enabled": true,
"sslRequired": "external",
"registrationAllowed": true,
"registrationEmailAsUsername": true,
"rememberMe": true,
"verifyEmail": false,
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
"maxFailureWaitSeconds": 900,
"minimumQuickLoginWaitSeconds": 60,
"waitIncrementSeconds": 60,
"quickLoginCheckMilliSeconds": 1000,
"maxDeltaTimeSeconds": 43200,
"failureFactor": 5,
"roles": {
"realm": [
{
"name": "user",
"description": "Standard user role"
},
{
"name": "admin",
"description": "Administrator role"
},
{
"name": "moderator",
"description": "Moderator role"
}
]
},
"clients": [
{
"clientId": "ink-platform",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "ink-platform-secret",
"redirectUris": [
"http://localhost:8081/*",
"http://localhost:3000/*"
],
"webOrigins": [
"http://localhost:8081",
"http://localhost:3000"
],
"protocol": "openid-connect",
"publicClient": false,
"directAccessGrantsEnabled": true,
"serviceAccountsEnabled": true,
"authorizationServicesEnabled": true,
"standardFlowEnabled": true,
"implicitFlowEnabled": false,
"fullScopeAllowed": true
}
]
}
4. Import Realm Configuration
# Using Keycloak Admin CLI
docker exec -it ink-keycloak /opt/keycloak/bin/kc.sh import \
--file /opt/keycloak/data/import/ink-realm.json
# Or via REST API
curl -X POST http://localhost:8080/admin/realms \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-d @config/keycloak/ink-realm.json
Configuration
Spring Security Configuration
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/public/**", "/actuator/health").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter jwtAuthenticationConverter =
new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(
grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000", "http://localhost:8081"));
configuration.setAllowedMethods(Arrays.asList(
"GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
}
JWT Decoder Configuration
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/JwtConfig.java
@Configuration
public class JwtConfig {
@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}")
private String jwkSetUri;
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder
.withJwkSetUri(jwkSetUri)
.build();
// Add custom validators
OAuth2TokenValidator<Jwt> validators = new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(issuerUri),
new JwtClaimValidator<List<String>>("roles",
roles -> roles != null && !roles.isEmpty())
);
jwtDecoder.setJwtValidator(validators);
return jwtDecoder;
}
}
Application Properties
# filepath: /Users/jetstart/dev/jetrev/ink/src/main/resources/application.yml
# ...existing code...
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${KEYCLOAK_ISSUER_URI:http://localhost:8080/realms/ink}
jwk-set-uri: ${KEYCLOAK_JWK_URI:http://localhost:8080/realms/ink/protocol/openid-connect/certs}
keycloak:
realm: ink
auth-server-url: http://localhost:8080
resource: ink-platform
credentials:
secret: ink-platform-secret
bearer-only: true
ssl-required: external
Usage Examples
Obtaining Access Token
# Direct Grant (Password Flow)
curl -X POST http://localhost:8080/realms/ink/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=ink-platform" \
-d "client_secret=ink-platform-secret" \
-d "grant_type=password" \
-d "username=testuser" \
-d "password=testpass"
# Response:
# {
# "access_token": "eyJhbGc...",
# "expires_in": 300,
# "refresh_expires_in": 1800,
# "refresh_token": "eyJhbGc...",
# "token_type": "Bearer"
# }
Using Access Token
# Store token
TOKEN="eyJhbGc..."
# Access protected endpoint
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8081/api/users/me
# Access admin endpoint (requires ADMIN role)
curl -H "Authorization: Bearer $TOKEN" \
http://localhost:8081/api/admin/users
Refreshing Token
# Using refresh token
curl -X POST http://localhost:8080/realms/ink/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=ink-platform" \
-d "client_secret=ink-platform-secret" \
-d "grant_type=refresh_token" \
-d "refresh_token=$REFRESH_TOKEN"
Authentication Service
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/service/AuthenticationService.java
@Service
@RequiredArgsConstructor
@Slf4j
public class AuthenticationService {
private final RestTemplate restTemplate;
@Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
private String issuerUri;
@Value("${keycloak.resource}")
private String clientId;
@Value("${keycloak.credentials.secret}")
private String clientSecret;
public TokenResponse authenticate(String username, String password) {
String tokenEndpoint = issuerUri + "/protocol/openid-connect/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
params.add("client_id", clientId);
params.add("client_secret", clientSecret);
params.add("grant_type", "password");
params.add("username", username);
params.add("password", password);
HttpEntity<MultiValueMap<String, String>> request =
new HttpEntity<>(params, headers);
try {
ResponseEntity<TokenResponse> response = restTemplate.exchange(
tokenEndpoint,
HttpMethod.POST,
request,
TokenResponse.class
);
return response.getBody();
} catch (HttpClientErrorException e) {
log.error("Authentication failed: {}", e.getMessage());
throw new AuthenticationException("Invalid credentials");
}
}
public TokenResponse refreshToken(String refreshToken) {
// ...existing code...
}
}
Verification
Testing Authentication
// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/security/AuthenticationTest.java
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@ActiveProfiles("test")
class AuthenticationTest {
@Autowired
private TestRestTemplate restTemplate;
@Test
void testPublicEndpointAccessible() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/public/health",
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void testProtectedEndpointRequiresAuth() {
ResponseEntity<String> response = restTemplate.getForEntity(
"/api/users/me",
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void testWithValidToken() {
String token = obtainValidToken();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
"/api/users/me",
HttpMethod.GET,
request,
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
Troubleshooting
Common Issues
Issue: Token validation fails
# Verify JWK endpoint is accessible
curl http://localhost:8080/realms/ink/protocol/openid-connect/certs
# Check token claims
echo $TOKEN | cut -d'.' -f2 | base64 -d | jq
# Verify issuer matches configuration
curl http://localhost:8081/actuator/env | grep issuer-uri
Issue: CORS errors
// Verify CORS configuration includes Keycloak origin
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(
"http://localhost:3000",
"http://localhost:8080" // Add Keycloak origin
));
// ...existing code...
}
Issue: Role mapping not working
// Ensure correct authorities claim name
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter converter =
new JwtGrantedAuthoritiesConverter();
converter.setAuthoritiesClaimName("roles"); // Match Keycloak claim
converter.setAuthorityPrefix("ROLE_");
// ...existing code...
}
Code Examples
Custom JWT Claims Extraction
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/JwtService.java
@Service
public class JwtService {
public String extractUsername(Jwt jwt) {
return jwt.getClaimAsString("preferred_username");
}
public List<String> extractRoles(Jwt jwt) {
return jwt.getClaimAsStringList("roles");
}
public String extractEmail(Jwt jwt) {
return jwt.getClaimAsString("email");
}
public Map<String, Object> extractAllClaims(Jwt jwt) {
return jwt.getClaims();
}
}
User Context from JWT
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/security/UserContextHolder.java
@Component
public class UserContextHolder {
public UserContext getCurrentUser() {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
Jwt jwt = jwtAuth.getToken();
return UserContext.builder()
.username(jwt.getClaimAsString("preferred_username"))
.email(jwt.getClaimAsString("email"))
.roles(jwt.getClaimAsStringList("roles"))
.userId(jwt.getClaimAsString("sub"))
.build();
}
return null;
}
}
Best Practices
- Secure Client Secrets: Never commit secrets to version control
- Token Expiration: Set appropriate token lifetimes (15-30 min for access, 1-2 hours for refresh)
- HTTPS in Production: Always use SSL/TLS in production
- Role Hierarchies: Use Keycloak composite roles for complex permissions
- Refresh Token Rotation: Enable refresh token rotation in production
- Brute Force Protection: Enable and configure Keycloak's brute force detection
- Regular Updates: Keep Keycloak updated to latest secure version
Related Documentation
Additional Resources
Next Steps: Learn about API Security to implement fine-grained authorization.