mcpField
O
oauth2-resource-server

oauth2-resource-server

github.com/rrezartprebrezaセキュリティ32897 回インストール6 日前に更新
リポジトリ

oauth2-resource-server について

OAuth2 リソースサーバーで JWT を検証し、スコープやロールで認可するための Spring Security 向けガイドです。ステートレス API の開発者を対象に、プロバイダー固有のクレームや設定上の注意点を扱います。

主な機能

  1. 01Spring Boot リソースサーバー設定
  2. 02JWT クレームの抽出
  3. 03スコープとロールによる認可
  4. 04プロバイダー別 JWT の指針
  5. 05よくあるセキュリティ設定の問題

活用シーン

  1. 01JWT による Spring API エンドポイントの保護
  2. 02スコープに基づくメソッドセキュリティの適用
  3. 03Auth0、Okta、Keycloak のクレーム処理

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/rrezartprebreza/spring-boot-skills --skill oauth2-resource-server

インストール後、Claude Code で/oauth2-resource-serverを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

agents/
openai.yaml
examples/
bad-resource-server-config.java
good-resource-server-config.java
templates/
JwtClaimExtractor.java
ResourceServerConfig.java

詳細

OAuth2 リソースサーバー

依存関係

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

セキュリティ設定

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers("/api/v1/admin/**").hasAuthority("SCOPE_admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthConverter()))
            )
            .build();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthConverter() {
        // Keep the defaults: the "scope" OR "scp" claim (Okta/Entra use scp) -> SCOPE_*.
        // Calling setAuthoritiesClaimName(...) replaces them — then hasAuthority("SCOPE_admin")
        // can never pass. Extend the defaults with Keycloak realm roles instead.
        var scopes = new JwtGrantedAuthoritiesConverter();
        var converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
            // Keycloak nests realm roles under realm_access.roles -> ROLE_* (for hasRole)
            Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
            if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) {
                roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
            }
            return authorities;
        });
        return converter;
    }
}

application.yml — 共通プロバイダー

# Keycloak
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
          jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
          audiences: my-api   # without it, tokens minted for ANY client in the realm are accepted

# Auth0
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-domain.auth0.com/
          audiences: https://your-api.example.com  # custom claim validation

カスタムクレームの抽出

@Component
public class JwtClaimExtractor {

    // Provider subjects are opaque strings ("auth0|64f1c2", Okta "00u1abcd..."), not UUIDs.
    public String getSubject(JwtAuthenticationToken token) {
        return token.getToken().getSubject();
    }

    public String getEmail(JwtAuthenticationToken token) {
        return token.getToken().getClaimAsString("email");
    }

    public List<String> getRoles(JwtAuthenticationToken token) {
        // Keycloak nests roles under realm_access.roles
        Map<String, Object> realmAccess = token.getToken().getClaimAsMap("realm_access");
        if (realmAccess == null) return List.of();
        return (List<String>) realmAccess.getOrDefault("roles", List.of());
    }
}

コントローラー — 現在のユーザーへのアクセス

@RestController
@RequiredArgsConstructor
public class OrderController {

    @GetMapping("/api/v1/orders/my")
    public ApiResponse<List<OrderResponse>> myOrders(
        @AuthenticationPrincipal Jwt jwt  // inject JWT directly
    ) {
        String subject = jwt.getSubject(); // opaque provider id — map it to your user, don't parse it
        return ApiResponse.ok(orderService.findByOwnerSubject(subject));
    }

    // Or with JwtAuthenticationToken for full principal
    @GetMapping("/api/v1/profile")
    public ApiResponse<ProfileResponse> profile(JwtAuthenticationToken token) {
        return ApiResponse.ok(userService.findByEmail(
            token.getToken().getClaimAsString("email")
        ));
    }
}

スコープによるメソッドセキュリティ

@PreAuthorize("hasAuthority('SCOPE_orders:read')")
public List<Order> findAll() { ... }

@PreAuthorize("hasRole('ADMIN') or @orderSecurity.isOwner(#orderId, authentication)")
public Order findById(UUID orderId) { ... }

// Custom security bean
@Component("orderSecurity")
public class OrderSecurityService {
    public boolean isOwner(UUID orderId, Authentication auth) {
        Jwt jwt = (Jwt) auth.getPrincipal();
        return orderRepository.existsByIdAndOwnerSubject(orderId, jwt.getSubject());
    }
}

注意事項

  • エージェントが JwtGrantedAuthoritiesConverter で setAuthoritiesClaimName(...) を呼び出すと、デフォルトの scope/scp の参照が置き換わるため、SCOPE_* 権限が消失します(Okta/Entra の scp スコープも破棄されます)。デフォルトを拡張してください
  • エージェントが jwt.getSubject() を UUID.fromString で解析すると、Auth0(auth0|...)や Okta(00u...)のサブジェクトは UUID ではないため、リクエストは 500 エラーで失敗します
  • エージェントが audiences を省略すると、リソースサーバーは発行者が任意のクライアント向けに発行したトークンをすべて受け入れてしまいます
  • エージェントがスコープのチェックに hasRole("ADMIN") を使っています。スコープには hasAuthority("SCOPE_admin") を使ってください
  • エージェントが issuer-uri の検証を忘れています。トークンの偽造を防ぐため、必ず設定してください
  • エージェントが Keycloak のロールを誤ってマッピングしています。ロールは realm_access.roles の下にネストされています
  • エージェントが getPrincipal() を直接使用しています。Jwt にキャストするか、@AuthenticationPrincipal Jwt を使用してください
  • エージェントが userDetailsService ビーンを追加しています。リソースサーバーでは不要です(ステートレス JWT)

コメント