Skip to content

필사 모드: Keycloak + Spring Security 6 統合 — Resource Server と OAuth2 Client の実践

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

はじめに

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

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

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

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

| 項目 | oauth2-resource-server | oauth2-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 デバッグチェックリスト

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

| 症状 | 最も多い原因 | 確認方法 |

| --- | --- | --- |

| すべてのリクエストが 401 | iss 不一致(内部/外部 URL の混用) | トークンの iss と issuer-uri を比較 |

| すべてのリクエストが 401 | aud 検証失敗(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 連携の構築方法を扱います。

参考資料

- [Spring Security Reference — OAuth2 Resource Server](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html)

- [Spring Security Reference — OAuth2 Client](https://docs.spring.io/spring-security/reference/servlet/oauth2/client/index.html)

- [Spring Security Reference — OIDC Logout](https://docs.spring.io/spring-security/reference/servlet/oauth2/login/logout.html)

- [Spring Security Reference — Multitenancy](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/multitenancy.html)

- [Spring Security Reference — Testing OAuth2](https://docs.spring.io/spring-security/reference/servlet/test/mockmvc/oauth2.html)

- [Keycloak Documentation — Securing Applications](https://www.keycloak.org/docs/latest/securing_apps/index.html)

- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)

- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)

- [OpenID Connect RP-Initiated Logout 1.0](https://openid.net/specs/openid-connect-rpinitiated-1_0.html)

- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)

- [RFC 6749 — The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)

- [RFC 7636 — Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)

- [RFC 9700 — Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)

- [OAuth 2.1 draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)

현재 단락 (1/295)

かつて標準だった Keycloak Spring アダプター(keycloak-spring-boot-starter)はすでに数年前に deprecated となり、もはや選択肢ではありません。20...

작성 글자: 0원문 글자: 13,918작성 단락: 0/295