本記事は Spring Boot 3.x(Spring Security 6.x) を前提としています。
.oauth2ResourceServer(oauth2 -> ...)のLambda DSLはSpring Security 6.x以降の構文です。Spring Boot 2.x系とは設定方法が異なります。
Spring Bootで自前のJWTを発行する実装記事はよく見かけますが、「外部IdPが発行したJWTを受け取る側の実装」は意外と情報が少ないですよね。KeycloakやCognito、Auth0などを認証基盤として採用しているなら、Spring Boot側はそのトークンを 検証 するだけでいい。それがリソースサーバーのパターンです。
この記事では spring-boot-starter-oauth2-resource-server を使って、外部IdP発行JWTの署名検証からスコープ認可・カスタムクレーム抽出まで一通り実装します。
リソースサーバーと自前JWT発行の違い
まずここを整理しておきましょう。
Spring BootでJWT認証を実装する記事では、Spring Boot自身がJWTを生成・発行する構成を扱っています。認証サーバーとリソースサーバーが同居しているイメージです。
今回はそれとは別の構成です。
- 認証サーバー :Keycloak・Cognito・Auth0など(Spring Bootとは別サービスとして動作)
- リソースサーバー :Spring Boot(トークンを受け取って検証するだけ)
SPA + REST APIやマイクロサービス構成では、こちらのパターンが主流です。Spring Boot側に「JWTを作る」コードは一切出てきません。
依存関係の追加
build.gradle に以下を追加します。
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-web'
}
spring-boot-starter-security は oauth2-resource-server スターターに内包されているので、別途追加は不要です。
JWK Set URIを設定する
application.yml にIdPのJWK Set URIを設定します。Spring Securityがここから公開鍵を取得して、JWT署名を自動で検証してくれます。
spring:
security:
oauth2:
resourceserver:
jwt:
# 使用するIdPの行だけを有効にしてください(複数を同時に設定することはできません)
# Keycloak
jwk-set-uri: http://localhost:8080/realms/myrealm/protocol/openid-connect/certs
# Auth0: https://{your-domain}/.well-known/jwks.json
# Cognito: https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json
本番環境での注意:jwk-set-uri のみ設定した場合、Spring Security はJWT署名と有効期限を検証しますが、iss(issuer)クレームの検証は行われません。本番環境では issuer-uri の使用を推奨します。OpenID Connectディスカバリーエンドポイントから JWK Set URI を自動検出しつつ、issuerの検証も同時に行ってくれます。
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8080/realms/myrealm
SecurityConfigを設定する
SecurityFilterChain で .oauth2ResourceServer() を有効にします。
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/items/**").hasAuthority("SCOPE_read")
.requestMatchers(HttpMethod.POST, "/api/items").hasAuthority("SCOPE_write")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
}
これだけで、Authorization: Bearer <token> ヘッダー付きリクエストのJWT検証が動きます。
JWT検証が走る仕組み
内部では次の流れで処理されます。
BearerTokenAuthenticationFilterがAuthorizationヘッダーからトークンを抽出JwtDecoderがJWK Set URIから公開鍵を取得して署名と有効期限を検証- 検証成功後、
JwtAuthenticationTokenがSecurityContextに格納される
OpaqueトークンのイントロスペクションはJWT方式とは別の設定が必要なので、本記事ではスコープ外とします。
コントローラーでクレームを取得する
JWTの scope クレームは SCOPE_ プレフィックス付きの GrantedAuthority に自動変換されます。コントローラーでクレームを参照したい場合は @AuthenticationPrincipal を使うと便利です。
@GetMapping("/api/items")
public List<Item> getItems(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
String email = jwt.getClaim("email");
// ...
}
カスタムJwtAuthenticationConverterでロールをマッピングする
Keycloakは realm_access.roles というネストしたクレームにロール情報を格納します(Keycloak Server Administration Guideのプロトコルマッパー設定が根拠です)。このままでは hasRole() で使えないので、カスタムコンバーターを実装します。デフォルトのスコープ権限(SCOPE_xxx)も維持したいので、両方を組み合わせましょう。
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
var defaultConverter = new JwtGrantedAuthoritiesConverter(); // Beanの初期化時に一度だけ生成
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<GrantedAuthority> authorities = new ArrayList<>();
// デフォルトの SCOPE_xxx 権限を保持
defaultConverter.convert(jwt).forEach(authorities::add);
// Keycloak の realm_access.roles をマッピング
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess != null) {
// realm_accessはMap<String, Object>型で返されるためキャスト警告を抑制
@SuppressWarnings("unchecked")
List<String> roles = (List<String>) realmAccess.get("roles");
if (roles != null) {
roles.stream()
.map(r -> new SimpleGrantedAuthority("ROLE_" + r))
.forEach(authorities::add);
}
}
return authorities;
});
return converter;
}
SecurityConfigの .jwt() にこのコンバーターを渡します。
.oauth2ResourceServer(oauth2 ->
oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
@PreAuthorizeとの連携
SecurityConfig に @EnableMethodSecurity を追加すると、メソッド単位で認可を設定できます。
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // これを追加
public class SecurityConfig {
// ...
}
あとはコントローラーのメソッドにアノテーションを付けるだけです。
@GetMapping("/api/admin/users")
@PreAuthorize("hasRole('ADMIN')")
public List<User> getUsers() { ... }
@PostMapping("/api/items")
@PreAuthorize("hasAuthority('SCOPE_write')")
public Item createItem(@RequestBody ItemRequest request) { ... }
詳細はメソッドレベル認可の記事を参照してください。
curlで動作確認する
まずKeycloakからアクセストークンを取得します。
# jq コマンドが必要です
# write スコープを持つクライアントでフルアクセス用トークンを取得
ACCESS_TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/myrealm/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=my-client" \
-d "client_secret=my-secret" | jq -r '.access_token')
# write スコープを持たないクライアントで read スコープのみのトークンを取得
READ_ONLY_TOKEN=$(curl -s -X POST \
http://localhost:8080/realms/myrealm/protocol/openid-connect/token \
-d "grant_type=client_credentials" \
-d "client_id=my-readonly-client" \
-d "client_secret=my-readonly-secret" \
-d "scope=openid read" | jq -r '.access_token')
3パターンで確認しましょう。
# 有効なトークン → 200 OK
curl -H "Authorization: Bearer $ACCESS_TOKEN" http://localhost:8090/api/items
# トークンなし → 401 Unauthorized
curl http://localhost:8090/api/items
# スコープ不足 → 403 Forbidden
curl -X POST -H "Authorization: Bearer $READ_ONLY_TOKEN" \
-H "Content-Type: application/json" \
-d '{}' http://localhost:8090/api/items
まとめ
spring-boot-starter-oauth2-resource-server を使うと、外部IdP発行JWTの検証は jwk-set-uri(本番では issuer-uri 推奨)の設定と .oauth2ResourceServer().jwt() の有効化だけで動きます。
- スコープ認可は
SCOPE_プレフィックスで対応 - Keycloakのロールは
JwtAuthenticationConverterでカスタムマッピング - メソッドレベル認可は
@EnableMethodSecurity+@PreAuthorizeと組み合わせる
SPA側からAPIを呼び出す場合はCORSの設定も必要になるので、CORSの設定記事も合わせてご覧ください。IdP側でGoogleなどのOAuth2認可コードフローを組み合わせる場合はGoogleソーシャルログインの記事も参考にしてください。