Skip to main content

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

  1. Secure Client Secrets: Never commit secrets to version control
  2. Token Expiration: Set appropriate token lifetimes (15-30 min for access, 1-2 hours for refresh)
  3. HTTPS in Production: Always use SSL/TLS in production
  4. Role Hierarchies: Use Keycloak composite roles for complex permissions
  5. Refresh Token Rotation: Enable refresh token rotation in production
  6. Brute Force Protection: Enable and configure Keycloak's brute force detection
  7. Regular Updates: Keep Keycloak updated to latest secure version

Additional Resources


Next Steps: Learn about API Security to implement fine-grained authorization.