Keycloak Java Adapter
ライブラリ
Keycloak Java Adapter
概要
Keycloak Java AdapterはJavaアプリケーション向けの認証・認可ライブラリで、OpenID ConnectとSAMLプロトコルを通じてKeycloakとの統合を提供します。
詳細
Keycloak Java Adapterは、Javaアプリケーション(Spring Boot、Jakarta EE、Servlet等)でKeycloak認証を簡単に実装できるライブラリです。しかし、重要な変更として、Java Adapterは非推奨となり、現在はOpenID Connect標準サポートへの移行が推奨されています。Spring Security、Jakarta EE等の主要フレームワークは既にOpenID Connect対応を内蔵しており、Keycloak専用アダプターは不要となりました。Java 8、11、17をサポートし、認証フロー、トークン管理、ユーザーセッション管理、ロールベースアクセス制御を提供します。Spring Boot Starter、Servlet Filter、JAX-RS統合等、様々な統合パターンをサポートします。Keycloak-quickstartsリポジトリには実装例が豊富に用意されており、現代的なJavaアプリケーションでの認証実装のベストプラクティスを学べます。
メリット・デメリット
メリット
- 標準準拠: OpenID Connect/SAML標準に準拠した実装
- フレームワーク統合: Spring、Jakarta EE等との自然な統合
- 多様な認証フロー: Authorization Code、Implicit、Hybrid Flow対応
- ロールベース認可: 細かい権限制御と認可機能
- エンタープライズ対応: 大規模企業システムでの実績
- 豊富な実装例: quickstartsでの詳細な実装サンプル
デメリット
- 非推奨: Java Adapterは既にEOL(End of Life)
- 移行推奨: フレームワーク標準のOIDC実装への移行が必要
- 複雑性: 高機能な分、初期設定の複雑さ
- 学習コスト: OpenID Connect、OAuth 2.0の深い理解が必要
- Java限定: Java以外の言語では使用不可
主要リンク
- GitHub - Keycloak
- Keycloak公式サイト
- Keycloak Quickstarts
- Keycloak ドキュメント
- OpenID Connect
- Spring Security OAuth2
書き方の例
Spring Boot + OAuth2でのKeycloak統合
// pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
// application.yml
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: my-app
client-secret: ${KEYCLOAK_CLIENT_SECRET}
scope: openid,profile,email
authorization-grant-type: authorization_code
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
authorization-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/auth
token-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/token
user-info-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/userinfo
jwk-set-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/certs
user-name-attribute: preferred_username
resourceserver:
jwt:
jwk-set-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/certs
// セキュリティ設定
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(authz -> authz
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard")
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
)
.logout(logout -> logout
.logoutSuccessUrl("http://localhost:8080/realms/myrealm/protocol/openid-connect/logout")
);
return http.build();
}
@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;
}
}
JAX-RS + Keycloak統合
// KeycloakのJAX-RS統合設定
@ApplicationPath("/api")
public class RestApplication extends Application {
@Override
public Set<Class<?>> getClasses() {
Set<Class<?>> classes = new HashSet<>();
classes.add(UserResource.class);
classes.add(AdminResource.class);
classes.add(SecurityExceptionMapper.class);
return classes;
}
}
// ユーザーリソースクラス
@Path("/users")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class UserResource {
@Context
private SecurityContext securityContext;
@GET
@Path("/profile")
@RolesAllowed("user")
public Response getUserProfile() {
String username = securityContext.getUserPrincipal().getName();
// Keycloakトークンからユーザー情報を取得
if (securityContext.getUserPrincipal() instanceof KeycloakPrincipal) {
KeycloakPrincipal<?> principal = (KeycloakPrincipal<?>) securityContext.getUserPrincipal();
AccessToken token = principal.getKeycloakSecurityContext().getToken();
UserProfile profile = UserProfile.builder()
.username(token.getPreferredUsername())
.email(token.getEmail())
.firstName(token.getGivenName())
.lastName(token.getFamilyName())
.roles(token.getRealmAccess().getRoles())
.build();
return Response.ok(profile).build();
}
return Response.status(Response.Status.UNAUTHORIZED).build();
}
@GET
@Path("/admin")
@RolesAllowed("admin")
public Response getAdminInfo() {
return Response.ok("Admin access granted").build();
}
}
// ユーザープロファイルモデル
public class UserProfile {
private String username;
private String email;
private String firstName;
private String lastName;
private Set<String> roles;
// コンストラクタ、ゲッター、セッター、ビルダー
public static Builder builder() {
return new Builder();
}
public static class Builder {
private String username;
private String email;
private String firstName;
private String lastName;
private Set<String> roles;
public Builder username(String username) {
this.username = username;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder firstName(String firstName) {
this.firstName = firstName;
return this;
}
public Builder lastName(String lastName) {
this.lastName = lastName;
return this;
}
public Builder roles(Set<String> roles) {
this.roles = roles;
return this;
}
public UserProfile build() {
UserProfile profile = new UserProfile();
profile.username = this.username;
profile.email = this.email;
profile.firstName = this.firstName;
profile.lastName = this.lastName;
profile.roles = this.roles;
return profile;
}
}
}
JWT検証とカスタム認証
// JWT検証サービス
@Service
public class KeycloakJwtService {
private final JwtDecoder jwtDecoder;
private final Logger logger = LoggerFactory.getLogger(KeycloakJwtService.class);
public KeycloakJwtService(@Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") String jwkSetUri) {
this.jwtDecoder = NimbusJwtDecoder.withJwkSetUri(jwkSetUri).build();
}
public Optional<UserInfo> validateAndExtractUserInfo(String token) {
try {
Jwt jwt = jwtDecoder.decode(token);
return Optional.of(extractUserInfo(jwt));
} catch (JwtException e) {
logger.warn("Invalid JWT token: {}", e.getMessage());
return Optional.empty();
}
}
private UserInfo extractUserInfo(Jwt jwt) {
return UserInfo.builder()
.subject(jwt.getSubject())
.username(jwt.getClaimAsString("preferred_username"))
.email(jwt.getClaimAsString("email"))
.firstName(jwt.getClaimAsString("given_name"))
.lastName(jwt.getClaimAsString("family_name"))
.roles(extractRoles(jwt))
.issuedAt(jwt.getIssuedAt())
.expiresAt(jwt.getExpiresAt())
.build();
}
@SuppressWarnings("unchecked")
private Set<String> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null && realmAccess.containsKey("roles")) {
List<String> roles = (List<String>) realmAccess.get("roles");
return new HashSet<>(roles);
}
return Collections.emptySet();
}
}
// ユーザー情報モデル
public class UserInfo {
private String subject;
private String username;
private String email;
private String firstName;
private String lastName;
private Set<String> roles;
private Instant issuedAt;
private Instant expiresAt;
public boolean hasRole(String role) {
return roles.contains(role);
}
public boolean isExpired() {
return expiresAt != null && Instant.now().isAfter(expiresAt);
}
// ビルダーパターンの実装
public static Builder builder() {
return new Builder();
}
public static class Builder {
// ビルダーの実装省略
}
}
REST APIでの認証フィルター
// JWT認証フィルター
@Component
public class JwtAuthenticationFilter implements Filter {
private final KeycloakJwtService jwtService;
private final Logger logger = LoggerFactory.getLogger(JwtAuthenticationFilter.class);
public JwtAuthenticationFilter(KeycloakJwtService jwtService) {
this.jwtService = jwtService;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
// 認証が不要なパスをスキップ
if (isPublicPath(httpRequest.getRequestURI())) {
chain.doFilter(request, response);
return;
}
String token = extractToken(httpRequest);
if (token == null) {
sendUnauthorized(httpResponse, "Missing Authorization header");
return;
}
Optional<UserInfo> userInfo = jwtService.validateAndExtractUserInfo(token);
if (userInfo.isEmpty()) {
sendUnauthorized(httpResponse, "Invalid token");
return;
}
if (userInfo.get().isExpired()) {
sendUnauthorized(httpResponse, "Token expired");
return;
}
// リクエストにユーザー情報を設定
httpRequest.setAttribute("userInfo", userInfo.get());
chain.doFilter(request, response);
}
private String extractToken(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
return authHeader.substring(7);
}
return null;
}
private boolean isPublicPath(String path) {
List<String> publicPaths = Arrays.asList("/public", "/health", "/actuator");
return publicPaths.stream().anyMatch(path::startsWith);
}
private void sendUnauthorized(HttpServletResponse response, String message) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\": \"" + message + "\"}");
}
}
// ロール認証アノテーション
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
String[] value();
}
// ロール認証インターセプター
@Component
public class RoleAuthorizationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if (!(handler instanceof HandlerMethod)) {
return true;
}
HandlerMethod handlerMethod = (HandlerMethod) handler;
RequireRole requireRole = handlerMethod.getMethodAnnotation(RequireRole.class);
if (requireRole == null) {
requireRole = handlerMethod.getBeanType().getAnnotation(RequireRole.class);
}
if (requireRole == null) {
return true;
}
UserInfo userInfo = (UserInfo) request.getAttribute("userInfo");
if (userInfo == null) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return false;
}
String[] requiredRoles = requireRole.value();
boolean hasRequiredRole = Arrays.stream(requiredRoles)
.anyMatch(userInfo::hasRole);
if (!hasRequiredRole) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
return false;
}
return true;
}
}
Keycloak管理API統合
// Keycloak管理クライアント
@Service
public class KeycloakAdminService {
private final Keycloak keycloakAdmin;
public KeycloakAdminService(
@Value("${keycloak.admin.server-url}") String serverUrl,
@Value("${keycloak.admin.realm}") String realm,
@Value("${keycloak.admin.client-id}") String clientId,
@Value("${keycloak.admin.client-secret}") String clientSecret) {
this.keycloakAdmin = KeycloakBuilder.builder()
.serverUrl(serverUrl)
.realm(realm)
.clientId(clientId)
.clientSecret(clientSecret)
.grantType(OAuth2Constants.CLIENT_CREDENTIALS)
.build();
}
public List<UserRepresentation> getAllUsers() {
return keycloakAdmin.realm("myrealm")
.users()
.list();
}
public Optional<UserRepresentation> getUserByUsername(String username) {
List<UserRepresentation> users = keycloakAdmin.realm("myrealm")
.users()
.search(username, true);
return users.stream().findFirst();
}
public String createUser(CreateUserRequest request) {
UserRepresentation user = new UserRepresentation();
user.setUsername(request.getUsername());
user.setEmail(request.getEmail());
user.setFirstName(request.getFirstName());
user.setLastName(request.getLastName());
user.setEnabled(true);
user.setEmailVerified(false);
Response response = keycloakAdmin.realm("myrealm")
.users()
.create(user);
if (response.getStatus() == 201) {
String locationHeader = response.getHeaderString("Location");
return locationHeader.substring(locationHeader.lastIndexOf('/') + 1);
} else {
throw new RuntimeException("Failed to create user: " + response.getStatusInfo());
}
}
public void updateUserRoles(String userId, List<String> roleNames) {
UserResource userResource = keycloakAdmin.realm("myrealm")
.users()
.get(userId);
RolesResource rolesResource = keycloakAdmin.realm("myrealm").roles();
List<RoleRepresentation> roles = roleNames.stream()
.map(roleName -> rolesResource.get(roleName).toRepresentation())
.collect(Collectors.toList());
userResource.roles().realmLevel().add(roles);
}
public void deleteUser(String userId) {
keycloakAdmin.realm("myrealm")
.users()
.get(userId)
.remove();
}
}
// ユーザー作成リクエストモデル
public class CreateUserRequest {
private String username;
private String email;
private String firstName;
private String lastName;
// コンストラクタ、ゲッター、セッター
}
// 管理APIコントローラー
@RestController
@RequestMapping("/admin/users")
@RequireRole("admin")
public class UserAdminController {
private final KeycloakAdminService adminService;
public UserAdminController(KeycloakAdminService adminService) {
this.adminService = adminService;
}
@GetMapping
public ResponseEntity<List<UserRepresentation>> getAllUsers() {
List<UserRepresentation> users = adminService.getAllUsers();
return ResponseEntity.ok(users);
}
@GetMapping("/{username}")
public ResponseEntity<UserRepresentation> getUserByUsername(@PathVariable String username) {
return adminService.getUserByUsername(username)
.map(user -> ResponseEntity.ok(user))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<Map<String, String>> createUser(@RequestBody CreateUserRequest request) {
try {
String userId = adminService.createUser(request);
return ResponseEntity.status(HttpStatus.CREATED)
.body(Map.of("userId", userId));
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(Map.of("error", e.getMessage()));
}
}
@PutMapping("/{userId}/roles")
public ResponseEntity<Void> updateUserRoles(
@PathVariable String userId,
@RequestBody List<String> roleNames) {
try {
adminService.updateUserRoles(userId, roleNames);
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
@DeleteMapping("/{userId}")
public ResponseEntity<Void> deleteUser(@PathVariable String userId) {
try {
adminService.deleteUser(userId);
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
}
}
}
統合テスト
// Keycloak統合テスト
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@Testcontainers
public class KeycloakIntegrationTest {
@Container
static KeycloakContainer keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:latest")
.withRealmImportFile("test-realm.json");
@Autowired
private TestRestTemplate restTemplate;
@LocalServerPort
private int port;
private String accessToken;
@BeforeEach
void setUp() {
// テスト用トークンを取得
accessToken = getAccessToken("test-user", "password");
}
@Test
void testProtectedEndpointWithValidToken() {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/api/users/profile",
HttpMethod.GET,
entity,
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
@Test
void testProtectedEndpointWithoutToken() {
ResponseEntity<String> response = restTemplate.getForEntity(
"http://localhost:" + port + "/api/users/profile",
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
void testAdminEndpointWithUserRole() {
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);
HttpEntity<String> entity = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(
"http://localhost:" + port + "/admin/users",
HttpMethod.GET,
entity,
String.class
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
}
private String getAccessToken(String username, String password) {
// OAuth2 Token Endpointからトークンを取得
String tokenEndpoint = keycloak.getAuthServerUrl() + "/realms/test/protocol/openid-connect/token";
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
body.add("grant_type", "password");
body.add("client_id", "test-client");
body.add("client_secret", "test-secret");
body.add("username", username);
body.add("password", password);
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.postForEntity(tokenEndpoint, request, Map.class);
@SuppressWarnings("unchecked")
Map<String, Object> responseBody = response.getBody();
return (String) responseBody.get("access_token");
}
}