マイクロサービスアーキテクチャやSPA(Single Page Application)バックエンドにおいて、OAuth2で保護されたREST APIを構築することは一般的なパターンです。本記事では、Spring Security 6.4のOAuth2リソースサーバー機能を使用して、JWTベースのAPI保護を実装する方法を解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
Spring Security 6.4.x
ビルドツール Maven または Gradle
IDE VS Code または IntelliJ IDEA

事前に以下の知識があると理解がスムーズです。

  • Spring Securityの基本概念(認証・認可の違い)
  • OAuth2とJWTの基礎知識
  • REST APIの実装経験

OAuth2リソースサーバーとは

OAuth2において、リソースサーバーはクライアントがアクセスしたいリソース(API)をホストするサーバーです。リソースサーバーは、認可サーバーから発行されたアクセストークンを検証し、適切な認可を行います。

OAuth2アーキテクチャにおける役割

OAuth2エコシステムは、以下のコンポーネントで構成されます。

flowchart LR
    subgraph "OAuth2 アーキテクチャ"
        A[リソースオーナー<br/>ユーザー] --> B[クライアント<br/>SPA/モバイルアプリ]
        B --> C[認可サーバー<br/>Keycloak/Auth0]
        C --> B
        B --> D[リソースサーバー<br/>REST API]
        D --> C
    end
    
    B -->|"1. 認可リクエスト"| C
    C -->|"2. アクセストークン発行"| B
    B -->|"3. APIリクエスト + Bearer Token"| D
    D -->|"4. トークン検証"| C
    D -->|"5. リソース返却"| B
コンポーネント 役割
リソースオーナー リソースの所有者(通常はエンドユーザー)
クライアント リソースにアクセスするアプリケーション
認可サーバー トークンを発行し、認証を管理するサーバー
リソースサーバー 保護されたリソース(API)を提供するサーバー

JWTベースのリソースサーバーの特徴

JWTを使用したリソースサーバーには以下の特徴があります。

特徴 説明
ステートレス検証 トークン自体に情報が含まれるため、認可サーバーへの問い合わせが不要
署名検証 公開鍵でトークンの真正性を確認
クレーム活用 トークン内のスコープや権限情報を直接利用
スケーラビリティ セッション管理が不要なため、水平スケーリングが容易

プロジェクトのセットアップ

依存関係の追加

OAuth2リソースサーバー機能を使用するには、以下の依存関係を追加します。

Mavenの場合(pom.xml):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
</dependencies>

Gradleの場合(build.gradle):

1
2
3
4
5
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
}

spring-boot-starter-oauth2-resource-serverは内部でspring-security-oauth2-resource-serverspring-security-oauth2-jose(JWT処理用)を含んでいます。

プロジェクト構成

本記事で作成するプロジェクトの構成は以下のとおりです。

src/main/java/com/example/resourceserver/
├── ResourceServerApplication.java
├── config/
│   └── SecurityConfig.java
├── controller/
│   └── MessageController.java
└── dto/
    └── MessageResponse.java

基本的なリソースサーバー設定

最小構成での設定

最もシンプルな構成では、認可サーバーのissuer-uriを指定するだけでリソースサーバーが機能します。

application.yml:

1
2
3
4
5
6
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-auth-server.example.com/realms/your-realm

この設定により、Spring Securityは以下の処理を自動で行います。

  1. 起動時に認可サーバーの.well-known/openid-configurationエンドポイントを照会
  2. JWKSet URIを取得し、公開鍵をダウンロード
  3. 受信したJWTの署名を検証
  4. issexpnbfクレームを検証

SecurityFilterChainの明示的な設定

より細かい制御が必要な場合は、SecurityFilterChainを明示的に定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package com.example.resourceserver.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // CSRF保護はステートレスAPIでは無効化
            .csrf(csrf -> csrf.disable())
            // セッション管理をステートレスに設定
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            // 認可ルールの設定
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/actuator/health").permitAll()
                .anyRequest().authenticated()
            )
            // OAuth2リソースサーバーの有効化
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(withDefaults())
            );
        
        return http.build();
    }
}

この設定のポイントは以下のとおりです。

設定項目 説明
csrf.disable() REST APIではCSRF保護は不要(トークンベース認証のため)
SessionCreationPolicy.STATELESS セッションを作成しないステートレス設定
oauth2ResourceServer().jwt() JWTベースのリソースサーバーとして機能

外部認可サーバーとの連携

Keycloakとの連携

Keycloakは、オープンソースのIdentity and Access Management(IAM)ソリューションです。

application.yml(Keycloak用):

1
2
3
4
5
6
7
8
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8080/realms/my-realm
          # JWK Set URIを明示的に指定する場合
          jwk-set-uri: http://localhost:8080/realms/my-realm/protocol/openid-connect/certs

Keycloakから発行されるJWTのペイロード例は以下のとおりです。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "exp": 1736672400,
  "iat": 1736668800,
  "jti": "abc123",
  "iss": "http://localhost:8080/realms/my-realm",
  "sub": "user-id-123",
  "typ": "Bearer",
  "azp": "my-client",
  "scope": "openid profile email",
  "realm_access": {
    "roles": ["user", "admin"]
  },
  "resource_access": {
    "my-client": {
      "roles": ["client-user"]
    }
  }
}

Auth0との連携

Auth0は、クラウドベースの認証・認可プラットフォームです。

application.yml(Auth0用):

1
2
3
4
5
6
7
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://your-tenant.auth0.com/
          audiences: https://your-api-identifier

Auth0ではaudiencesプロパティでAPIのオーディエンスを指定することが推奨されます。

Amazon Cognito との連携

AWS Cognitoを認可サーバーとして使用する場合の設定例です。

application.yml(Cognito用):

1
2
3
4
5
6
7
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX
          jwk-set-uri: https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX/.well-known/jwks.json

JWTデコーダーのカスタマイズ

カスタムJwtDecoderの作成

デフォルトの設定では対応できない要件がある場合、JwtDecoderをカスタマイズできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.example.resourceserver.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.jwt.JwtClaimNames;
import org.springframework.security.oauth2.jwt.JwtClaimValidator;
import org.springframework.security.oauth2.jwt.JwtDecoder;
import org.springframework.security.oauth2.jwt.JwtDecoders;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;

import java.time.Duration;
import java.util.List;

@Configuration
public class JwtDecoderConfig {

    @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri}")
    private String issuerUri;

    @Value("${spring.security.oauth2.resourceserver.jwt.audiences}")
    private String audience;

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);

        // バリデーターの組み合わせ
        OAuth2TokenValidator<Jwt> withIssuer = new JwtIssuerValidator(issuerUri);
        OAuth2TokenValidator<Jwt> withTimestamp = new JwtTimestampValidator(Duration.ofSeconds(60));
        OAuth2TokenValidator<Jwt> withAudience = audienceValidator();

        OAuth2TokenValidator<Jwt> combinedValidator = new DelegatingOAuth2TokenValidator<>(
            withIssuer,
            withTimestamp,
            withAudience
        );

        jwtDecoder.setJwtValidator(combinedValidator);
        return jwtDecoder;
    }

    private OAuth2TokenValidator<Jwt> audienceValidator() {
        return new JwtClaimValidator<List<String>>(
            JwtClaimNames.AUD,
            aud -> aud != null && aud.contains(audience)
        );
    }
}

クロックスキューの調整

分散システムでは、サーバー間の時刻のずれ(クロックスキュー)に対応する必要があります。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
@Bean
public JwtDecoder jwtDecoderWithClockSkew() {
    NimbusJwtDecoder jwtDecoder = JwtDecoders.fromIssuerLocation(issuerUri);

    // クロックスキューを120秒に設定
    OAuth2TokenValidator<Jwt> withClockSkew = new DelegatingOAuth2TokenValidator<>(
        new JwtTimestampValidator(Duration.ofSeconds(120)),
        new JwtIssuerValidator(issuerUri)
    );

    jwtDecoder.setJwtValidator(withClockSkew);
    return jwtDecoder;
}

タイムアウトの設定

認可サーバーとの通信タイムアウトをカスタマイズする場合は、RestOperationsを設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.web.client.RestOperations;

@Bean
public JwtDecoder jwtDecoderWithTimeout(RestTemplateBuilder builder) {
    RestOperations rest = builder
        .setConnectTimeout(Duration.ofSeconds(30))
        .setReadTimeout(Duration.ofSeconds(30))
        .build();

    return NimbusJwtDecoder.withIssuerLocation(issuerUri)
        .restOperations(rest)
        .build();
}

スコープベースのアクセス制御

基本的なスコープによる認可

OAuth2のスコープを使用してAPIエンドポイントへのアクセスを制御します。JWTのscopeクレームは自動的にSCOPE_プレフィックス付きの権限に変換されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.resourceserver.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

import static org.springframework.security.config.Customizer.withDefaults;
import static org.springframework.security.oauth2.core.authorization.OAuth2AuthorizationManagers.hasScope;

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(authorize -> authorize
                // パブリックエンドポイント
                .requestMatchers("/api/public/**").permitAll()
                // スコープベースのアクセス制御
                .requestMatchers("/api/messages/**").access(hasScope("messages:read"))
                .requestMatchers("/api/users/**").access(hasScope("users:read"))
                // 管理者エンドポイント
                .requestMatchers("/api/admin/**").access(hasScope("admin"))
                // その他は認証必須
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(withDefaults())
            );

        return http.build();
    }
}

メソッドレベルセキュリティ

@PreAuthorizeアノテーションを使用して、メソッドレベルでスコープを検証できます。

1
2
3
4
5
6
7
8
9
package com.example.resourceserver.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.resourceserver.controller;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.resourceserver.dto.MessageResponse;

import java.util.List;

@RestController
@RequestMapping("/api/messages")
public class MessageController {

    @GetMapping
    @PreAuthorize("hasAuthority('SCOPE_messages:read')")
    public List<MessageResponse> getMessages(@AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();
        // メッセージ取得ロジック
        return List.of(
            new MessageResponse("1", "Hello, " + userId, "system"),
            new MessageResponse("2", "Welcome to the API", "system")
        );
    }

    @PostMapping
    @PreAuthorize("hasAuthority('SCOPE_messages:write')")
    public MessageResponse createMessage(
            @RequestBody MessageRequest request,
            @AuthenticationPrincipal Jwt jwt) {
        String userId = jwt.getSubject();
        // メッセージ作成ロジック
        return new MessageResponse("3", request.content(), userId);
    }

    @DeleteMapping("/{id}")
    @PreAuthorize("hasAuthority('SCOPE_messages:delete') or hasAuthority('SCOPE_admin')")
    public void deleteMessage(@PathVariable String id) {
        // メッセージ削除ロジック
    }

    public record MessageRequest(String content) {}
}

カスタム権限マッピング

Keycloakのロールをマッピング

Keycloakでは、ロール情報がrealm_access.rolesresource_access.<client>.rolesに格納されます。これをSpring Securityの権限にマッピングします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
package com.example.resourceserver.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

@Configuration
public class JwtAuthConverterConfig {

    @Bean
    public Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter() {
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(new KeycloakGrantedAuthoritiesConverter());
        return converter;
    }

    static class KeycloakGrantedAuthoritiesConverter implements Converter<Jwt, Collection<GrantedAuthority>> {

        @Override
        public Collection<GrantedAuthority> convert(Jwt jwt) {
            List<GrantedAuthority> authorities = new ArrayList<>();

            // realm_accessからロールを抽出
            Map<String, Object> realmAccess = jwt.getClaim("realm_access");
            if (realmAccess != null) {
                @SuppressWarnings("unchecked")
                List<String> roles = (List<String>) realmAccess.get("roles");
                if (roles != null) {
                    authorities.addAll(
                        roles.stream()
                            .map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
                            .collect(Collectors.toList())
                    );
                }
            }

            // scopeからも権限を抽出
            String scope = jwt.getClaimAsString("scope");
            if (scope != null) {
                authorities.addAll(
                    List.of(scope.split(" ")).stream()
                        .map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
                        .collect(Collectors.toList())
                );
            }

            return authorities;
        }
    }
}

SecurityFilterChainへの適用

カスタムコンバーターをSecurityFilterChainに設定します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter;

    public SecurityConfig(Converter<Jwt, AbstractAuthenticationToken> jwtAuthenticationConverter) {
        this.jwtAuthenticationConverter = jwtAuthenticationConverter;
    }

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers("/api/messages/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(jwtAuthenticationConverter)
                )
            );

        return http.build();
    }
}

エラーハンドリング

認証エラーのカスタマイズ

トークンが無効な場合やアクセスが拒否された場合のレスポンスをカスタマイズします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
package com.example.resourceserver.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;

import java.io.IOException;
import java.time.LocalDateTime;
import java.util.Map;

@Configuration
public class AuthenticationErrorConfig {

    @Bean
    public AuthenticationEntryPoint authenticationEntryPoint(ObjectMapper objectMapper) {
        return (request, response, authException) -> {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);

            Map<String, Object> errorResponse = Map.of(
                "timestamp", LocalDateTime.now().toString(),
                "status", HttpStatus.UNAUTHORIZED.value(),
                "error", "Unauthorized",
                "message", getErrorMessage(authException),
                "path", request.getRequestURI()
            );

            objectMapper.writeValue(response.getOutputStream(), errorResponse);
        };
    }

    private String getErrorMessage(AuthenticationException exception) {
        String message = exception.getMessage();
        if (message.contains("expired")) {
            return "アクセストークンの有効期限が切れています";
        } else if (message.contains("invalid")) {
            return "無効なアクセストークンです";
        } else if (message.contains("malformed")) {
            return "トークンの形式が不正です";
        }
        return "認証に失敗しました";
    }
}

SecurityFilterChainへの適用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
@Bean
public SecurityFilterChain securityFilterChain(
        HttpSecurity http,
        AuthenticationEntryPoint authenticationEntryPoint) throws Exception {
    http
        .csrf(csrf -> csrf.disable())
        .sessionManagement(session -> session
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
        )
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/api/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(withDefaults())
            .authenticationEntryPoint(authenticationEntryPoint)
        );

    return http.build();
}

実装例:完全なリソースサーバー

DTOクラス

1
2
3
4
5
6
7
package com.example.resourceserver.dto;

public record MessageResponse(
    String id,
    String content,
    String authorId
) {}

コントローラー

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.example.resourceserver.controller;

import com.example.resourceserver.dto.MessageResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class MessageController {

    @GetMapping("/public/health")
    public Map<String, String> health() {
        return Map.of("status", "UP");
    }

    @GetMapping("/messages")
    @PreAuthorize("hasAuthority('SCOPE_messages:read')")
    public List<MessageResponse> getMessages(@AuthenticationPrincipal Jwt jwt) {
        return List.of(
            new MessageResponse("1", "Hello, " + jwt.getSubject(), "system"),
            new MessageResponse("2", "Welcome to the protected API", "system")
        );
    }

    @GetMapping("/profile")
    public Map<String, Object> getProfile(@AuthenticationPrincipal Jwt jwt) {
        return Map.of(
            "sub", jwt.getSubject(),
            "email", jwt.getClaimAsString("email"),
            "name", jwt.getClaimAsString("name"),
            "scopes", jwt.getClaimAsString("scope")
        );
    }
}

統合テスト

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.resourceserver;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@SpringBootTest
@AutoConfigureMockMvc
class MessageControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void パブリックエンドポイントはトークンなしでアクセス可能() throws Exception {
        mockMvc.perform(get("/api/public/health"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.status").value("UP"));
    }

    @Test
    void 適切なスコープがあればメッセージを取得できる() throws Exception {
        mockMvc.perform(get("/api/messages")
                .with(SecurityMockMvcRequestPostProcessors.jwt()
                    .jwt(jwt -> jwt
                        .subject("test-user")
                        .claim("scope", "messages:read")
                    )
                ))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$[0].authorId").value("system"));
    }

    @Test
    void トークンがない場合は401エラー() throws Exception {
        mockMvc.perform(get("/api/messages"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    void スコープが不足している場合は403エラー() throws Exception {
        mockMvc.perform(get("/api/messages")
                .with(SecurityMockMvcRequestPostProcessors.jwt()
                    .jwt(jwt -> jwt
                        .subject("test-user")
                        .claim("scope", "profile:read")
                    )
                ))
            .andExpect(status().isForbidden());
    }
}

JWT認証フローのシーケンス

リソースサーバーにおけるJWT認証の処理フローを以下に示します。

sequenceDiagram
    participant Client as クライアント
    participant Filter as BearerTokenAuthenticationFilter
    participant Provider as JwtAuthenticationProvider
    participant Decoder as JwtDecoder
    participant Converter as JwtAuthenticationConverter
    participant Context as SecurityContext

    Client->>Filter: APIリクエスト<br/>Authorization: Bearer <token>
    Filter->>Filter: Bearerトークン抽出
    Filter->>Provider: BearerTokenAuthenticationToken
    Provider->>Decoder: トークン検証依頼
    Decoder->>Decoder: 署名検証
    Decoder->>Decoder: クレーム検証(iss, exp, aud)
    Decoder-->>Provider: Jwtオブジェクト
    Provider->>Converter: 権限変換依頼
    Converter->>Converter: scope→GrantedAuthority変換
    Converter-->>Provider: Collection<GrantedAuthority>
    Provider-->>Filter: JwtAuthenticationToken
    Filter->>Context: 認証情報を設定
    Context-->>Client: APIレスポンス

本番運用での考慮事項

JWKキャッシュの設定

本番環境では、JWKの取得をキャッシュすることでパフォーマンスを向上させます。

1
2
3
4
5
6
@Bean
public JwtDecoder jwtDecoder(CacheManager cacheManager) {
    return NimbusJwtDecoder.withIssuerLocation(issuerUri)
        .cache(cacheManager.getCache("jwks-cache"))
        .build();
}

application.yml:

1
2
3
4
5
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=100,expireAfterWrite=5m

CORS設定

SPAからのアクセスを許可する場合は、CORS設定が必要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration configuration = new CorsConfiguration();
    configuration.setAllowedOrigins(List.of("https://your-spa.example.com"));
    configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    configuration.setExposedHeaders(List.of("X-Request-Id"));
    configuration.setMaxAge(3600L);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", configuration);
    return source;
}

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        // 他の設定...
    return http.build();
}

監査ログの実装

APIアクセスの監査ログを出力するフィルターを追加します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Component
public class AuditLoggingFilter extends OncePerRequestFilter {

    private static final Logger auditLogger = LoggerFactory.getLogger("AUDIT");

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain) throws ServletException, IOException {

        long startTime = System.currentTimeMillis();

        try {
            filterChain.doFilter(request, response);
        } finally {
            Authentication auth = SecurityContextHolder.getContext().getAuthentication();
            String userId = auth != null ? auth.getName() : "anonymous";

            auditLogger.info("API_ACCESS user={} method={} path={} status={} duration={}ms",
                userId,
                request.getMethod(),
                request.getRequestURI(),
                response.getStatus(),
                System.currentTimeMillis() - startTime
            );
        }
    }
}

まとめ

本記事では、Spring Security 6.4を使用してOAuth2リソースサーバーを構築する方法を解説しました。

項目 内容
基本設定 issuer-uriの指定だけで動作する最小構成
外部IdP連携 Keycloak、Auth0、Cognitoとの連携方法
JWTカスタマイズ バリデーター、タイムアウト、キャッシュの設定
アクセス制御 スコープベース、ロールベースの認可設定
権限マッピング カスタムJwtAuthenticationConverterの実装
エラーハンドリング 認証エラーのカスタムレスポンス
テスト MockMvcを使用した統合テスト

OAuth2リソースサーバーを適切に構成することで、外部の認可サーバーと連携したセキュアなAPIを効率的に構築できます。マイクロサービスアーキテクチャにおいては、各サービスをリソースサーバーとして構成し、中央の認可サーバーでトークンを発行する構成が一般的です。

参考リンク