Skip to content
Published on

Keycloak + Spring Security 6 統合 — Resource Server と OAuth2 Client の実践

Authors

はじめに

かつて標準だった Keycloak Spring アダプター(keycloak-spring-boot-starter)はすでに数年前に deprecated となり、もはや選択肢ではありません。2026 年現在の正解は明確です。Spring Security 6 の標準 OAuth2 スタックで Keycloak を普通の OIDC Provider として扱うことです。標準スタックは Keycloak のバージョンアップの影響を受けず、Spring Boot 3.x の自動設定とも自然に噛み合います。

Keycloak 26.6 は FAPI 2.0 Security Profile Final、EdDSA 署名、JWT Authorization Grant など、トークン発行側の標準準拠を一段と強化しました。さらに OAuth 2.1 draft が事実上のベストプラクティスとして定着し、PKCE の必須化や Implicit フローの廃止といった原則が Spring のデフォルトとも一致するようになりました。本記事は Spring Boot 3.4 + Spring Security 6.4 を前提に、Keycloak 統合の全工程をコード中心に整理します。

Resource Server vs OAuth2 Client — どちらを使うべきか

最初に整理すべき概念は、2 つのスターターの役割の違いです。

項目oauth2-resource-serveroauth2-client
役割API サーバー(トークン検証)Web アプリ(ログイン主体)
認証フローBearer トークンの受信・検証Authorization Code + PKCE
セッションステートレス推奨セッションベース
トークン保管保管しないOAuth2AuthorizedClientService
代表的な用途REST API、マイクロサービスサーバーレンダリング Web、BFF

判断基準はシンプルです。ブラウザを Keycloak のログインページへリダイレクトする必要があれば client、Authorization ヘッダーの Bearer トークンを検証するだけなら resource server です。BFF(Backend for Frontend)パターンなら、1 つのアプリケーションが両方を兼ねることもあります。フロントのリクエストは oauth2Login で受け、ダウンストリームの API 呼び出しには保管した access token を伝搬する構成です。

依存関係は次のとおりです。

dependencies {
    // API サーバーの場合
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    // ログイン主体の Web アプリの場合
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
}

issuer-uri ベースの自動設定

Spring Boot の自動設定は issuer-uri 1 つから始まります。

# Resource Server
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://kc.example.com/realms/myrealm

起動時に Spring は issuer-uri の後ろに /.well-known/openid-configuration を付けて OIDC discovery ドキュメントを取得し、その中の jwks_uri から署名検証鍵を取得します。このとき自動構成される JwtDecoder には 2 つの検証器がデフォルトで組み込まれます。

  • タイムスタンプ検証: exp、nbf(デフォルトで 60 秒の clock skew を許容)
  • issuer 検証: iss クレームが issuer-uri と完全一致するか

ここで実務の事故第 1 号が発生します。コンテナ内部では Keycloak へ内部 DNS でアクセスし、トークンの iss には外部ドメインが入る環境では、iss 不一致によりすべてのリクエストが 401 になります。解決策は issuer-uri と jwk-set-uri を分離することです。

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # iss クレーム検証の基準(トークンに刻まれた外部ドメイン)
          issuer-uri: https://kc.example.com/realms/myrealm
          # 実際の鍵取得は内部ネットワーク経由
          jwk-set-uri: http://keycloak.internal:8080/realms/myrealm/protocol/openid-connect/certs

OAuth2 Client 側の設定は registration と provider の 2 ブロックで構成されます。

spring:
  security:
    oauth2:
      client:
        registration:
          keycloak:
            client-id: web-app
            client-secret: WEB_APP_SECRET
            authorization-grant-type: authorization_code
            scope: openid, profile, email
        provider:
          keycloak:
            issuer-uri: https://kc.example.com/realms/myrealm

Spring Security 6 は public client に対して PKCE を自動適用し、confidential client も client-authentication-method の設定で PKCE を併用できます。OAuth 2.1 の方向性と一致するデフォルトです。

JwtDecoder のカスタマイズ — audience 検証の追加

自動構成された JwtDecoder は aud を検証しません。前回の記事で扱った Audience mapper でトークンに aud を入れたなら、検証も自分で追加して初めて意味があります。

@Configuration
public class JwtDecoderConfig {

    @Bean
    JwtDecoder jwtDecoder(OAuth2ResourceServerProperties props) {
        String issuer = props.getJwt().getIssuerUri();
        NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuer);

        OAuth2TokenValidator<Jwt> issuerValidator =
                JwtValidators.createDefaultWithIssuer(issuer);
        OAuth2TokenValidator<Jwt> audienceValidator = new JwtClaimValidator<List<String>>(
                JwtClaimNames.AUD,
                aud -> aud != null && aud.contains("order-api"));

        decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
                issuerValidator, audienceValidator));
        return decoder;
    }
}

検証失敗時、Spring は 401 とともに WWW-Authenticate ヘッダーにエラーの詳細を入れて返します。デバッグの際はレスポンスボディではなくこのヘッダーを見るべきだと覚えておくと、時間を節約できます。

Realm ロールと Client ロールを GrantedAuthority にマッピング

Keycloak トークンのロール構造は、Spring が期待する scope ベースの構造と異なります。トークンのペイロードは次のようになっています。

{
  "realm_access": {
    "roles": ["platform-admin", "offline_access"]
  },
  "resource_access": {
    "order-api": {
      "roles": ["order-viewer", "order-editor"]
    }
  },
  "scope": "openid profile email"
}

デフォルトの JwtAuthenticationConverter は scope クレームだけを読んで SCOPE_openid のような権限を作るのみで、realm_access と resource_access は無視します。そのため hasRole チェックがすべて 403 になるのが、Keycloak + Spring 入門者の定番症状です。変換器の全コードは次のとおりです。

public class KeycloakJwtAuthenticationConverter
        implements Converter<Jwt, AbstractAuthenticationToken> {

    private static final String CLIENT_ID = "order-api";

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        Collection<GrantedAuthority> authorities = Stream.concat(
                realmRoles(jwt), clientRoles(jwt, CLIENT_ID))
            .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
            .collect(Collectors.toSet());

        String principalName = jwt.getClaimAsString("preferred_username");
        return new JwtAuthenticationToken(jwt, authorities, principalName);
    }

    private Stream<String> realmRoles(Jwt jwt) {
        Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
        if (realmAccess == null) {
            return Stream.empty();
        }
        Object roles = realmAccess.get("roles");
        return roles instanceof Collection<?> c
                ? c.stream().map(Object::toString) : Stream.empty();
    }

    @SuppressWarnings("unchecked")
    private Stream<String> clientRoles(Jwt jwt, String clientId) {
        Map<String, Object> resourceAccess = jwt.getClaimAsMap("resource_access");
        if (resourceAccess == null) {
            return Stream.empty();
        }
        Object client = resourceAccess.get(clientId);
        if (!(client instanceof Map)) {
            return Stream.empty();
        }
        Object roles = ((Map<String, Object>) client).get("roles");
        return roles instanceof Collection<?> c
                ? c.stream().map(Object::toString) : Stream.empty();
    }
}

SecurityFilterChain に組み込みます。

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("platform-admin")
                .anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(jwt ->
                jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter())));
        return http.build();
    }
}

1 つ設計上の判断が必要です。realm ロールと client ロールの prefix をどう区別するかです。両方を ROLE_ prefix に統合すると名前衝突の可能性があるため、規模が大きい場合は client ロールに ROLE_CLIENT_ のような別 prefix を与えるか、realm ロールだけを使うポリシーに単純化するのが運用上有利です。

Method Security

クラスレベルの @EnableMethodSecurity を有効にすれば、サービス層で宣言的に認可を表現できます。

@Service
public class OrderService {

    @PreAuthorize("hasRole('order-viewer')")
    public List<OrderSummary> list(String dept) { ... }

    @PreAuthorize("hasRole('order-editor') and #order.dept == authentication.token.claims['dept']")
    public Order create(Order order) { ... }

    @PostAuthorize("returnObject.ownerId == authentication.name")
    public Order findOne(Long id) { ... }
}

2 番目の例のように SpEL で JWT クレームを直接参照すれば、前回の記事で設計した dept クレームがそのままデータ範囲認可(row-level authorization)の入力になります。ただし SpEL が複雑になるとテストと追跡が困難になるため、3 行を超える条件はカスタム AuthorizationManager Bean に抽出することをおすすめします。

セッションベースの oauth2Login と OIDC ログアウト

サーバーレンダリングの Web アプリなら、oauth2Login と RP-Initiated Logout を一緒に構成します。

@Configuration
@EnableWebSecurity
public class WebAppSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http,
            ClientRegistrationRepository registrations) throws Exception {

        OidcClientInitiatedLogoutSuccessHandler logoutHandler =
                new OidcClientInitiatedLogoutSuccessHandler(registrations);
        logoutHandler.setPostLogoutRedirectUri("{baseUrl}/logged-out");

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/logged-out").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(Customizer.withDefaults())
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));
        return http.build();
    }
}

ここで頻発する運用上の問題が 2 つあります。

  • ローカルログアウトしかされない問題: logoutSuccessHandler なしのデフォルト logout だけでは Spring のセッションが切れるだけで、Keycloak の SSO セッションは生きており、次のリクエストで即座に自動再ログインされます。必ず OidcClientInitiatedLogoutSuccessHandler で Keycloak の end_session_endpoint までフローをつなぐ必要があります。
  • post logout redirect の拒否: Keycloak クライアント設定の Valid post logout redirect URIs にリダイレクト先が登録されていないと、Keycloak がエラーページを表示します。デプロイドメイン変更時に見落としやすい項目です。

逆方向のログアウトも考慮すべきです。Keycloak 管理者がセッションを強制終了したときに Spring のセッションも一緒に整理するには、OIDC Back-Channel Logout を使います。Spring Security 6.2 から oidcLogout DSL でサポートされており、Keycloak クライアント設定の Backchannel logout URL に Spring が公開するエンドポイントを登録すれば完了です。

http.oidcLogout(logout -> logout.backChannel(Customizer.withDefaults()));

マルチテナント — 複数 Realm のトークンを 1 つの API で受ける

B2B SaaS でテナントごとに realm を分離している場合、リソースサーバーは複数の issuer のトークンを同時に検証する必要があります。Spring Security が提供する JwtIssuerAuthenticationManagerResolver が定石です。

@Configuration
public class MultiTenantSecurityConfig {

    @Bean
    SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        // 許可された issuer のリストに基づく — 信頼リスト方式
        JwtIssuerAuthenticationManagerResolver resolver =
                JwtIssuerAuthenticationManagerResolver.fromTrustedIssuers(
                        "https://kc.example.com/realms/tenant-a",
                        "https://kc.example.com/realms/tenant-b");

        http
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.authenticationManagerResolver(resolver));
        return http.build();
    }
}

issuer ごとの AuthenticationManager は初回リクエスト時に lazy に生成されキャッシュされます。テナントが動的に増える環境では、trusted issuer のリストを DB から読むカスタム resolver を実装すべきですが、iss クレームの値をそのまま信頼して discovery を実行してはいけません。攻撃者が自分の偽 issuer を iss に入れたトークンを送る SSRF・トークン偽造のベクターになるため、必ず事前登録された許可リストと照合する必要があります。

WebFlux 対応

リアクティブスタックでは型が変わるだけで、概念は同じです。変換器は Reactive アダプターでラップします。

@Configuration
@EnableWebFluxSecurity
public class ReactiveSecurityConfig {

    @Bean
    SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
        ReactiveJwtAuthenticationConverterAdapter converter =
                new ReactiveJwtAuthenticationConverterAdapter(
                        new KeycloakJwtAuthenticationConverter());

        http
            .csrf(ServerHttpSecurity.CsrfSpec::disable)
            .authorizeExchange(ex -> ex
                .pathMatchers("/actuator/health/**").permitAll()
                .anyExchange().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(jwt ->
                jwt.jwtAuthenticationConverter(converter)));
        return http.build();
    }
}

Spring Cloud Gateway を BFF として使う場合は、oauth2-client + TokenRelay フィルターの組み合わせで、セッションの access token をダウンストリームへ伝搬するパターンが標準です。

spring:
  cloud:
    gateway:
      default-filters:
        - TokenRelay=
      routes:
        - id: order-api
          uri: http://order-api.internal:8080
          predicates:
            - Path=/api/orders/**

テスト — spring-security-test と Mock JWT

認可ロジックは Keycloak なしでテストできてこそ、CI が高速かつ安定します。spring-security-test の jwt() ポストプロセッサーが中核ツールです。

@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {

    @Autowired MockMvc mockMvc;

    @Test
    void adminEndpointRequiresPlatformAdmin() throws Exception {
        mockMvc.perform(get("/api/admin/stats")
                .with(jwt()
                    .jwt(j -> j.claim("preferred_username", "alice")
                               .claim("dept", "platform"))
                    .authorities(new SimpleGrantedAuthority("ROLE_platform-admin"))))
            .andExpect(status().isOk());
    }

    @Test
    void viewerCannotCreateOrder() throws Exception {
        mockMvc.perform(post("/api/orders")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{}")
                .with(jwt().authorities(new SimpleGrantedAuthority("ROLE_order-viewer"))))
            .andExpect(status().isForbidden());
    }
}

注意すべき点は、jwt() ポストプロセッサーで authorities を直接指定するとカスタム変換器をバイパスすることです。変換器自体のリグレッションを捕捉するには、raw クレームだけを入れて変換器を通すテストを別途用意します。

@Test
void converterMapsRealmAndClientRoles() {
    Jwt jwt = Jwt.withTokenValue("t")
        .header("alg", "RS256")
        .claim("realm_access", Map.of("roles", List.of("platform-admin")))
        .claim("resource_access", Map.of("order-api",
                Map.of("roles", List.of("order-editor"))))
        .claim("preferred_username", "alice")
        .build();

    var token = new KeycloakJwtAuthenticationConverter().convert(jwt);

    assertThat(token.getAuthorities())
        .extracting(GrantedAuthority::getAuthority)
        .containsExactlyInAnyOrder("ROLE_platform-admin", "ROLE_order-editor");
}

本物の Keycloak との契約を検証したい場合は、Testcontainers の Keycloak モジュールで realm export JSON をロードし、E2E ステージで実行する構成をおすすめします。ユニット・スライステストは mock JWT、夜間パイプラインは Testcontainers と役割を分けるのです。

トラブルシューティング — 401/403 デバッグチェックリスト

最後に、現場で最も多く遭遇する症状別の原因を整理します。

症状最も多い原因確認方法
すべてのリクエストが 401iss 不一致(内部/外部 URL の混用)トークンの iss と issuer-uri を比較
すべてのリクエストが 401aud 検証失敗(mapper の欠落)WWW-Authenticate ヘッダーを確認
断続的な 401サーバーの時計ドリフト、鍵ローテーション直後exp/iat とサーバー時刻を比較
認証は通るが 403ロール変換器の未適用、prefix 不一致authorities をログ出力
403 + CSRF エラーステートレス API で CSRF が有効POST だけ 403 か確認
ログアウト後に自動再ログインRP-Initiated Logout 未構成Keycloak セッションの残存を確認

デバッグの第一歩はいつも同じです。セキュリティログを有効にすることです。

logging:
  level:
    org.springframework.security: TRACE
    org.springframework.security.oauth2: TRACE

TRACE レベルでは、どのフィルターがリクエストを拒否したか、JwtDecoder がどの検証器で失敗したか、変換された authorities が何かがすべて出力されます。401 は認証(トークン自体)の問題、403 は認可(権限マッピング)の問題という区別を確実にするだけで、原因の探索範囲が半分になります。401 ならトークンをデコードして iss・aud・exp を確認し、403 なら変換器と hasRole 式の大文字小文字・prefix を確認するのが定石の順序です。

おわりに

Spring Security 6 の標準スタックで Keycloak を扱えば、アダプター時代のバージョン依存問題から完全に解放されます。要点をまとめます。

  • トークンを検証するだけなら resource server、ログインを主導するなら oauth2-client。BFF は両者の組み合わせです。
  • issuer-uri の自動設定がカバーするのは iss 検証までです。aud 検証は JwtDecoder のカスタマイズで自分で追加します。
  • realm_access / resource_access のロールはカスタム JwtAuthenticationConverter で GrantedAuthority にマッピングしてこそ hasRole が動作します。
  • ログアウトは RP-Initiated Logout と Back-Channel Logout の双方向で構成します。
  • マルチテナントは trusted issuer 許可リストベースの resolver で、テストは mock JWT と Testcontainers の二層構造で進めます。

次回の記事では、Keycloak の Identity Brokering によるソーシャルログインと外部 IdP 連携の構築方法を扱います。

参考資料