oauth2-resource-server について
OAuth2 リソースサーバーで JWT を検証し、スコープやロールで認可するための Spring Security 向けガイドです。ステートレス API の開発者を対象に、プロバイダー固有のクレームや設定上の注意点を扱います。
主な機能
- 01Spring Boot リソースサーバー設定
- 02JWT クレームの抽出
- 03スコープとロールによる認可
- 04プロバイダー別 JWT の指針
- 05よくあるセキュリティ設定の問題
活用シーン
- 01JWT による Spring API エンドポイントの保護
- 02スコープに基づくメソッドセキュリティの適用
- 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.yamlexamples/bad-resource-server-config.javagood-resource-server-config.javatemplates/JwtClaimExtractor.javaResourceServerConfig.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)
コメント
Markdown 対応