Keycloak Java Adapter

認証JavaKeycloakOpenID ConnectSAMLSSO

ライブラリ

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以外の言語では使用不可

主要リンク

書き方の例

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");
    }
}