# Spring Security 6: OAuth2リソースサーバーの設定 > Spring Security 6でOAuth2リソースサーバーを設定する実践ガイドです。JWT検証、issuer設定、スコープ管理、Keycloak連携について説明します。 - Published: 2026-03-16 - Updated: 2026-05-01 - Author: SharpSkill - Tags: spring security, oauth2, resource server, jwt, spring boot - Reading time: 14 min --- OAuth2リソースサーバーは、Keycloak、Auth0、OktaなどのKeycloak外部の認可サーバーが発行したJWTトークンを検証することでAPIを保護します。アプリケーションが独自のトークンを生成するカスタムJWT認証とは異なり、リソースサーバーはアイデンティティ管理を専用のIdentity Provider(IdP)に完全に委譲します。 > **リソースサーバーとカスタムJWTの違い** > > リソースサーバーはトークンを生成しません。検証のみを行います。この責務分離はセキュリティを強化し、アイデンティティ管理を集約することでアーキテクチャを単純化します。 ## OAuth2リソースサーバーのアーキテクチャ OAuth2フローには3つの主要なアクターが関与します。クライアント(フロントエンドまたはモバイルアプリケーション)は認可サーバーからアクセストークンを取得します。次にこのトークンをリソースサーバーへの各リクエストに含めます。リソースサーバーは認可サーバーの公開鍵を使用して署名を検証することでトークンの妥当性を確認します。 このアーキテクチャにはいくつかの利点があります。必要なすべての情報がJWTトークン内に含まれているため、リソースサーバーはステートレスを保ちます。非対称署名検証により、IdPへのネットワーク呼び出しなしで検証が行われます。複数のAPIが同じ認可サーバーを共有でき、ユーザー管理が単純化されます。 ``` ┌──────────────┐ 1. Login ┌─────────────────────┐ │ Client │ ───────────────► │ Authorization │ │ (SPA/App) │ │ Server (Keycloak) │ │ │ ◄─────────────────│ │ └──────────────┘ 2. JWT Token └─────────────────────┘ │ │ │ 3. Request + Bearer Token │ ▼ ▼ ┌──────────────────────┐ ┌─────────────────────┐ │ Resource Server │ ◄─────── │ JWKS Endpoint │ │ (Spring Boot API) │ 4. Public Keys (cached) │ └──────────────────────┘ └─────────────────────┘ ``` リソースサーバーは起動時に公開鍵をダウンロードしてキャッシュし、各リクエストでのネットワーク呼び出しを回避します。 ## OAuth2リソースサーバー用Maven依存関係 リソースサーバーの設定には2つの特定の依存関係が必要です。`oauth2-resource-server`スターターは基本インフラを提供し、`oauth2-jose`はJWTのデコードと検証に必要なクラスを含みます。 ```xml org.springframework.boot spring-boot-starter-oauth2-resource-server org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-validation ``` JJWTのような追加の依存関係は必要ありません。Spring Securityはトークン処理のために`spring-security-oauth2-jose`に含まれるNimbus JOSE+JWTライブラリを使用します。 ## issuer-uriによる基本設定 リソースサーバーの最小設定は2行のみです。Spring SecurityはOpenID Connect Discoveryプロトコルを通じて自動的にIdPのエンドポイントを発見します。 ```yaml # application.yml spring: security: oauth2: resourceserver: jwt: # 認可サーバーのURI # Springは/.well-known/openid-configurationを介して公開鍵を自動的に取得します issuer-uri: https://keycloak.example.com/realms/myrealm ``` `issuer-uri`から、Spring Securityは`{issuer-uri}/.well-known/openid-configuration`にリクエストを送信し、認可サーバーのメタデータを取得します。次に、トークン署名の検証に使用する公開鍵を含むJWKS(JSON Web Key Set)のURLを抽出します。 ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity public class SecurityConfig { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http // Disable CSRF for stateless APIs .csrf(csrf -> csrf.disable()) // Authorization rules .authorizeHttpRequests(auth -> auth // Public endpoints .requestMatchers("/api/public/**").permitAll() .requestMatchers("/actuator/health").permitAll() // All other requests require a valid token .anyRequest().authenticated() ) // Enable OAuth2 JWT validation .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults()) ) // Stateless mode required for Resource Servers .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } } ``` この設定はAPIを保護するのに十分です。Spring Securityはトークンの署名を自動的に検証し、タイムスタンプ(`exp`、`nbf`、`iat`)を確認し、`iss`クレームが設定された`issuer-uri`と一致することを保証します。 > **IdPなしでの起動** > > デフォルトでは、認可サーバーに到達できない場合、アプリケーションは起動しません。独立した起動を可能にするには、`issuer-uri`の代わりに`jwk-set-uri`を明示的に設定する必要があります。 ## jwk-set-uriによる高度な設定 認可サーバーがOpenID Connect Discoveryをサポートしていない場合、またはアプリケーションがIdPへのネットワーク依存なしに起動する必要がある場合、明示的なJWKS設定が好まれます。 ```yaml # application.yml spring: security: oauth2: resourceserver: jwt: # Direct URL to JWKS (public keys) jwk-set-uri: https://keycloak.example.com/realms/myrealm/protocol/openid-connect/certs # Expected value of the "iss" claim in tokens issuer-uri: https://keycloak.example.com/realms/myrealm # Allowed audiences ("aud" claim) audiences: - my-api - account ``` このアプローチはより多くの制御を提供します。オーディエンス検証は他のサービス向けのトークンの使用を防ぎます。`frontend-app`アプリケーション向けに発行されたトークンは、APIが`my-api`と`account`のオーディエンスのみを受け入れる場合、拒否されます。 ```java // SecurityConfig.java @Configuration @EnableWebSecurity public class SecurityConfig { @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuerUri; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() .anyRequest().authenticated() ) // Custom JWT configuration .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt .jwtAuthenticationConverter(jwtAuthenticationConverter()) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } // Custom converter to extract authorities from the token @Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter(); // SCOPE_ prefix for authorities (e.g., SCOPE_read, SCOPE_write) grantedAuthoritiesConverter.setAuthorityPrefix("SCOPE_"); // Claim containing scopes (OAuth2 standard) grantedAuthoritiesConverter.setAuthoritiesClaimName("scope"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter( grantedAuthoritiesConverter ); return jwtAuthenticationConverter; } } ``` `JwtAuthenticationConverter`はトークンのクレームを、セキュリティ式で使用可能な`GrantedAuthority`オブジェクトに変換します。 ## Keycloakロールの抽出 KeycloakはOAuth2標準とは異なる方法でロールを構造化します。ロールは`realm_access.roles`(レルムロール)または`resource_access.{client}.roles`(クライアント固有のロール)に格納されます。カスタムコンバーターがこの情報を抽出します。 ```java // KeycloakJwtAuthenticationConverter.java @Component public class KeycloakJwtAuthenticationConverter implements Converter { private static final String REALM_ACCESS_CLAIM = "realm_access"; private static final String RESOURCE_ACCESS_CLAIM = "resource_access"; private static final String ROLES_CLAIM = "roles"; @Value("${keycloak.client-id}") private String clientId; @Override public AbstractAuthenticationToken convert(Jwt jwt) { // Combine realm and client roles Collection authorities = Stream.concat( extractRealmRoles(jwt).stream(), extractClientRoles(jwt).stream() ).collect(Collectors.toSet()); // Return authentication token with extracted authorities return new JwtAuthenticationToken(jwt, authorities, extractUsername(jwt)); } // Extract realm-level roles private Collection extractRealmRoles(Jwt jwt) { Map realmAccess = jwt.getClaim(REALM_ACCESS_CLAIM); if (realmAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) realmAccess.get(ROLES_CLAIM); if (roles == null) { return Collections.emptyList(); } // ROLE_ prefix for compatibility with hasRole() return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } // Extract client-specific roles private Collection extractClientRoles(Jwt jwt) { Map resourceAccess = jwt.getClaim(RESOURCE_ACCESS_CLAIM); if (resourceAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") Map clientAccess = (Map) resourceAccess.get(clientId); if (clientAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) clientAccess.get(ROLES_CLAIM); if (roles == null) { return Collections.emptyList(); } return roles.stream() .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase())) .collect(Collectors.toList()); } // Extract username from token private String extractUsername(Jwt jwt) { // Keycloak uses "preferred_username" by default String username = jwt.getClaimAsString("preferred_username"); if (username != null) { return username; } // Fallback to subject (usually the user UUID) return jwt.getSubject(); } } ``` セキュリティ設定への統合は`jwtAuthenticationConverter`パラメーターを介して行われます。 ```java // SecurityConfig.java @Configuration @EnableWebSecurity @EnableMethodSecurity @RequiredArgsConstructor public class SecurityConfig { private final KeycloakJwtAuthenticationConverter keycloakConverter; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { return http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**").permitAll() // Protection by Keycloak role .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt // Use Keycloak converter .jwtAuthenticationConverter(keycloakConverter) ) ) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) .build(); } } ``` 抽出されたロールは、`@PreAuthorize("hasRole('ADMIN')")`アノテーションや`requestMatchers().hasRole()`式で使用できるようになりました。 ## カスタムトークン検証 ビジネス要件に応じて追加の検証が必要になる場合があります。カスタムクレームの確認、オーディエンスの検証、最大有効期間の制御などです。 ```java // CustomJwtValidator.java @Component public class CustomJwtValidator implements OAuth2TokenValidator { private static final OAuth2Error AUDIENCE_ERROR = new OAuth2Error("invalid_token", "Token audience is not valid", null); private static final OAuth2Error CUSTOM_CLAIM_ERROR = new OAuth2Error("invalid_token", "Required custom claim missing", null); @Value("${app.jwt.required-audience}") private String requiredAudience; @Value("${app.jwt.required-tenant-claim:tenant_id}") private String tenantClaimName; @Override public OAuth2TokenValidatorResult validate(Jwt jwt) { List errors = new ArrayList<>(); // Audience validation if (!validateAudience(jwt)) { errors.add(AUDIENCE_ERROR); } // Custom business claim validation if (!validateTenantClaim(jwt)) { errors.add(CUSTOM_CLAIM_ERROR); } if (!errors.isEmpty()) { return OAuth2TokenValidatorResult.failure(errors); } return OAuth2TokenValidatorResult.success(); } private boolean validateAudience(Jwt jwt) { List audiences = jwt.getAudience(); return audiences != null && audiences.contains(requiredAudience); } private boolean validateTenantClaim(Jwt jwt) { // Check for required business claim String tenantId = jwt.getClaimAsString(tenantClaimName); return tenantId != null && !tenantId.isBlank(); } } ``` カスタムバリデーターは`JwtDecoder`の設定に統合されます。 ```java // JwtConfig.java @Configuration @RequiredArgsConstructor public class JwtConfig { private final CustomJwtValidator customValidator; @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}") private String issuerUri; @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri}") private String jwkSetUri; @Bean public JwtDecoder jwtDecoder() { // Create decoder with JWKS URL NimbusJwtDecoder jwtDecoder = NimbusJwtDecoder .withJwkSetUri(jwkSetUri) .build(); // Combine default validators with custom validator OAuth2TokenValidator defaultValidators = JwtValidators.createDefaultWithIssuer(issuerUri); OAuth2TokenValidator combinedValidator = new DelegatingOAuth2TokenValidator<>(defaultValidators, customValidator); jwtDecoder.setJwtValidator(combinedValidator); return jwtDecoder; } } ``` > **検証のパフォーマンス** > > 同期検証は処理スレッドをブロックします。外部呼び出し(データベース、サードパーティサービス)を必要とする検証には、非同期フィルターまたは後続検証が好ましいです。 ## コントローラーでのトークン情報へのアクセス Spring Securityはコントローラー内でJWTトークン情報にアクセスするための複数の方法を提供します。 ```java // UserController.java @RestController @RequestMapping("/api/users") public class UserController { // Direct JWT injection via @AuthenticationPrincipal @GetMapping("/me") public ResponseEntity getCurrentUser( @AuthenticationPrincipal Jwt jwt ) { String userId = jwt.getSubject(); String email = jwt.getClaimAsString("email"); String username = jwt.getClaimAsString("preferred_username"); List roles = extractRoles(jwt); return ResponseEntity.ok(new UserInfoResponse( userId, email, username, roles )); } // Alternative with JwtAuthenticationToken to access authorities @GetMapping("/profile") public ResponseEntity getProfile( JwtAuthenticationToken authentication ) { Jwt jwt = authentication.getToken(); Collection authorities = authentication.getAuthorities() .stream() .map(GrantedAuthority::getAuthority) .toList(); return ResponseEntity.ok(new ProfileResponse( jwt.getSubject(), jwt.getClaimAsString("name"), authorities )); } // Access scopes for conditional business logic @GetMapping("/data") @PreAuthorize("hasAuthority('SCOPE_read')") public ResponseEntity getData( @AuthenticationPrincipal Jwt jwt ) { boolean canWrite = hasScope(jwt, "write"); // Business logic adapted according to permissions DataResponse response = buildDataResponse(jwt.getSubject(), canWrite); return ResponseEntity.ok(response); } private List extractRoles(Jwt jwt) { Map realmAccess = jwt.getClaim("realm_access"); if (realmAccess == null) { return Collections.emptyList(); } @SuppressWarnings("unchecked") List roles = (List) realmAccess.get("roles"); return roles != null ? roles : Collections.emptyList(); } private boolean hasScope(Jwt jwt, String scope) { String scopes = jwt.getClaimAsString("scope"); return scopes != null && scopes.contains(scope); } } ``` ```java // UserInfoResponse.java public record UserInfoResponse( String userId, String email, String username, List roles ) {} // ProfileResponse.java public record ProfileResponse( String userId, String name, Collection authorities ) {} ``` `@AuthenticationPrincipal`アノテーションは、`SecurityContextHolder`から認証情報を手動で取得することを回避します。 ## OAuth2認証エラーの処理 特定のエラー処理は、APIコンシューマーの開発者体験を向上させます。 ```java // OAuth2SecurityExceptionHandler.java @RestControllerAdvice public class OAuth2SecurityExceptionHandler { private static final Logger log = LoggerFactory.getLogger(OAuth2SecurityExceptionHandler.class); // Missing token or invalid format @ExceptionHandler(AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleAuthenticationException(AuthenticationException ex) { log.warn("Authentication failed: {}", ex.getMessage()); return new ErrorResponse( "UNAUTHORIZED", "Authentication required. Provide a valid Bearer token.", Map.of("error", ex.getMessage()) ); } // Access denied despite valid token @ExceptionHandler(AccessDeniedException.class) @ResponseStatus(HttpStatus.FORBIDDEN) public ErrorResponse handleAccessDeniedException(AccessDeniedException ex) { log.warn("Access denied: {}", ex.getMessage()); return new ErrorResponse( "FORBIDDEN", "Insufficient permissions for this resource", null ); } // OAuth2-specific error (expired token, invalid signature, etc.) @ExceptionHandler(OAuth2AuthenticationException.class) @ResponseStatus(HttpStatus.UNAUTHORIZED) public ErrorResponse handleOAuth2Exception(OAuth2AuthenticationException ex) { OAuth2Error error = ex.getError(); log.warn("OAuth2 authentication error: {} - {}", error.getErrorCode(), error.getDescription()); String message = switch (error.getErrorCode()) { case "invalid_token" -> "Token is invalid or expired"; case "insufficient_scope" -> "Token does not have required scopes"; default -> "Authentication failed"; }; return new ErrorResponse( error.getErrorCode().toUpperCase(), message, Map.of("details", error.getDescription()) ); } } // ErrorResponse.java public record ErrorResponse( String code, String message, Map details ) {} ``` 標準化されたOAuth2エラーコード(`invalid_token`、`insufficient_scope`)はクライアント側の処理を容易にします。 ## マルチ環境設定 設定は環境によって異なります。開発環境ではローカルKeycloakサーバーを使用でき、本番環境ではAuth0などのマネージドIdPが引き継ぎます。 ```yaml # application.yml (common configuration) app: jwt: required-audience: my-api --- # application-dev.yml spring: config: activate: on-profile: dev security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:8180/realms/dev-realm keycloak: client-id: my-api-dev logging: level: org.springframework.security: DEBUG --- # application-prod.yml spring: config: activate: on-profile: prod security: oauth2: resourceserver: jwt: issuer-uri: ${OAUTH2_ISSUER_URI} audiences: ${OAUTH2_AUDIENCES:my-api} keycloak: client-id: ${KEYCLOAK_CLIENT_ID} logging: level: org.springframework.security: WARN ``` 本番環境では機密値は環境変数またはシークレットマネージャーから取得されます。 ## モックJWTを使用した統合テスト 統合テストはSpring Security Testのユーティリティを使用して有効なJWTトークンをシミュレートします。 ```java // ResourceServerIntegrationTest.java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc class ResourceServerIntegrationTest { @Autowired private MockMvc mockMvc; @Test void shouldRejectRequestWithoutToken() throws Exception { mockMvc.perform(get("/api/users/me")) .andExpect(status().isUnauthorized()); } @Test @WithMockJwtAuth( claims = @OpenIdClaims(sub = "user-123", preferredUsername = "john.doe"), authorities = {"ROLE_USER"} ) void shouldAllowAuthenticatedUser() throws Exception { mockMvc.perform(get("/api/users/me")) .andExpect(status().isOk()) .andExpect(jsonPath("$.userId").value("user-123")) .andExpect(jsonPath("$.username").value("john.doe")); } @Test void shouldAllowAccessWithValidJwt() throws Exception { mockMvc.perform(get("/api/users/me") .with(jwt() .jwt(jwt -> jwt .subject("user-456") .claim("preferred_username", "jane.doe") .claim("email", "jane@example.com") ) .authorities(new SimpleGrantedAuthority("ROLE_USER")) )) .andExpect(status().isOk()) .andExpect(jsonPath("$.email").value("jane@example.com")); } @Test void shouldDenyAccessWithoutAdminRole() throws Exception { mockMvc.perform(get("/api/admin/users") .with(jwt() .authorities(new SimpleGrantedAuthority("ROLE_USER")) )) .andExpect(status().isForbidden()); } @Test void shouldAllowAdminAccess() throws Exception { mockMvc.perform(get("/api/admin/users") .with(jwt() .authorities(new SimpleGrantedAuthority("ROLE_ADMIN")) )) .andExpect(status().isOk()); } } ``` `spring-security-test`の`jwt()`ユーティリティは認可サーバーを必要とせずにモックトークンを作成します。 ```java // SecurityTestConfig.java @TestConfiguration public class SecurityTestConfig { // Mocked JwtDecoder for tests @Bean @Primary public JwtDecoder jwtDecoder() { return token -> { // Return a mocked JWT for tests return Jwt.withTokenValue(token) .header("alg", "RS256") .subject("test-user") .claim("scope", "read write") .build(); }; } } ``` ## まとめ Spring Security 6でOAuth2リソースサーバーを設定することで、トークン検証を集約しAPIセキュリティを単純化できます。認可サーバーが認証とトークン発行を処理する一方、APIは検証とアイデンティティ情報の抽出に焦点を当てます。 **デプロイメントチェックリスト:** - ✅ ニーズに応じて`issuer-uri`または`jwk-set-uri`を設定する - ✅ 他サービスのトークンの誤用を防ぐためにオーディエンスを検証する - ✅ Keycloakロールを抽出するカスタムコンバーター - ✅ 標準化されたコードでのOAuth2エラー処理 - ✅ モックトークンを使用した統合テスト - ✅ クライアントとリソースサーバー間でHTTPSを必須にする - ✅ 環境固有の設定 - ✅ 認証失敗試行のロギング このアーキテクチャは、複数のAPIが同じIdentity Providerを共有するマイクロサービスエコシステムに自然に統合されます。 --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/ja/blog/spring-boot/spring-security-6-oauth2-resource-server