本記事は 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-securityoauth2-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検証が走る仕組み

内部では次の流れで処理されます。

  1. BearerTokenAuthenticationFilterAuthorization ヘッダーからトークンを抽出
  2. JwtDecoder がJWK Set URIから公開鍵を取得して署名と有効期限を検証
  3. 検証成功後、JwtAuthenticationTokenSecurityContext に格納される

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ソーシャルログインの記事も参考にしてください。