OAuth 2.0とOpenID ConnectのSpring Boot 3での実装例

1. 概要

OAuth 2.0とOpenID Connect (OIDC)はしばしば一緒に言及されるため、多くの人が両者を同じものだと考えています。しかし実は、両者は異なる問題を解決しています。OAuth 2.0は認可(アプリケーションがユーザーの代わりにAPIを呼び出すことを許可する)を扱います。OIDCは認証(ユーザーが誰であるかを知る)を扱います。

この記事では、Authorization Code + PKCEフローについて理解します。その後、2つのSpring Boot 3アプリケーションを構築します。1つはOIDCでログインするクライアント、もう1つはJWT形式のアクセストークンを検証するリソースサーバーです。記事の最後には、リソースサーバーのテストと一般的なエラーのリストが含まれています。

2. OAuth 2.0とOpenID Connectの違い

2.1. OAuth 2.0の役割

OAuth 2.0は4つの役割を定義しています:

  • Resource Owner: ユーザー、データの所有者(例:注文を持つ顧客)。
  • Client: データにアクセスしたいアプリケーション(例:webアプリケーションshop-web)。
  • Authorization Server: ユーザーがログインして権限を付与し、トークンを発行する場所。例:Keycloak、Okta、Auth0。
  • Resource Server: データを含むAPI(例:order-api)。

OAuth 2.0の結果はアクセストークンです。このトークンはリソースサーバー用で、クライアント用ではありません。クライアントは単にそれを保持して送信するだけです。クライアントはトークンを読んでユーザーが誰であるかを推測してはいけません。

2.2. OIDCが追加する機能

OIDCはOAuth 2.0上に構築されたレイヤーです。クライアントがopenidスコープを要求する場合、認可サーバーはIDトークンを追加で返します。これは常にJWTで、署名されており、ログイン情報が含まれています:

{
  "iss": "http://localhost:8180/realms/shop",
  "sub": "customer-42",
  "aud": "shop-web",
  "exp": 1760353200,
  "iat": 1760352900,
  "nonce": "n-0S6_WzA2Mj",
  "email": "lan@example.com",
  "name": "Nguyễn Thị Lan"
}

重要なクレームには以下が含まれます:

  • iss: トークンを発行した者。
  • sub: ユーザーの一意で安定な識別子。
  • aud: このトークンがどのクライアント用か。
  • nonce: リプレイ攻撃を防止します。

次のようにまとめることができます:

Access token  -> API (resource server)に送信 -> 「何ができるか」
ID token      -> クライアントが読んでログイン -> 「ユーザーは誰か」

3. Authorization Code + PKCEフロー

webアプリケーション、SPA、およびモバイルアプリケーションでは、Authorization Codeフローを使用する必要があります。これはPKCE(Proof Key for Code Exchange)と組み合わせて使用されます。Implicit flowとPassword grantは推奨されなくなっています。

1. ユーザーがshop-web上の「ログイン」をクリック
2. shop-webがランダムなcode_verifierを生成し、code_challenge = SHA256(code_verifier)を計算
3. ブラウザを次へリダイレクト:
   /authorize?response_type=code&client_id=shop-web&scope=openid profile email
             &redirect_uri=...&state=xyz&nonce=abc
             &code_challenge=...&code_challenge_method=S256
4. ユーザーがKeycloakでログインし、Keycloakが以下にリダイレクト:
   /login/oauth2/code/shop?code=SplxlOBe&state=xyz
5. shop-web(バックチャネル)がcode + code_verifier + client_secretを含むPOST /tokenを呼び出す
6. Keycloakが返す: access_token、id_token、refresh_token
7. shop-webがid_tokenを検証(署名、iss、aud、exp、nonce)-> セッションを作成
8. shop-webがorder-apiをheader Authorization: Bearer <access_token>で呼び出す

各パラメータが特定の種類の攻撃を防ぎます:

  • stateはコールバックステップでのCSRF攻撃を防ぎます。
  • nonceはIDトークンを正しいログインセッションに関連付けます。
  • PKCEにより、たとえ誰かがcodeを盗んでも、code_verifierがないため、それをトークンに交換することはできません。

良いニュースは、Spring Securityがほぼすべてのステップを自動で実行することです。

4. OIDCでログインするクライアントの構築

4.1. 依存性と設定

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Keycloakがポート8180で動作し、realm shopとclient shop-webがあると仮定します:

spring:
  security:
    oauth2:
      client:
        registration:
          shop:
            provider: keycloak
            client-id: shop-web
            client-secret: ${SHOP_CLIENT_SECRET}
            authorization-grant-type: authorization_code
            scope: openid, profile, email, orders.read
            redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
        provider:
          keycloak:
            issuer-uri: http://localhost:8180/realms/shop

issuer-uriを宣言するだけで十分です。Springは/.well-known/openid-configurationを読んで、authorize、token、jwks、およびend_sessionエンドポイントを自動的に取得します。スコープopenidは、OAuth 2.0フローをOIDCログインに変えるものです。

4.2. セキュリティ設定

@Configuration
@EnableWebSecurity
public class WebSecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http,
                                            ClientRegistrationRepository registrations) throws Exception {
        var resolver = new DefaultOAuth2AuthorizationRequestResolver(
                registrations, "/oauth2/authorization");
        resolver.setAuthorizationRequestCustomizer(OAuth2AuthorizationRequestCustomizers.withPkce());

        var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registrations);
        logoutHandler.setPostLogoutRedirectUri("{baseUrl}");

        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/").permitAll()
                .anyRequest().authenticated())
            .oauth2Login(login -> login
                .authorizationEndpoint(ep -> ep.authorizationRequestResolver(resolver)))
            .logout(logout -> logout.logoutSuccessHandler(logoutHandler));
        return http.build();
    }
}

この設定は3つのことを行います:

  • oauth2Loginはセクション3のフロー全体を有効にします。リダイレクト、コールバック、コード交換、IDトークン検証が含まれます。
  • withPkce()は、confidential client(secretを持つクライアント)にもcode_challengeを追加します。Public clientはSpringにより既にPKCEが有効です。
  • OidcClientInitiatedLogoutSuccessHandlerはKeycloak側でもログアウトします(RP-initiated logout)。これがないと、ユーザーが「ログイン」をクリックしても、パスワードを入力せずに直接ログインしてしまいます。

4.3. ユーザー情報の取得とAPIの呼び出し

@RestController
public class AccountController {

    private final RestClient orderApi = RestClient.create("http://localhost:8081");

    @GetMapping("/me")
    public Map<String, Object> me(@AuthenticationPrincipal OidcUser user) {
        return Map.of(
                "subject", user.getSubject(),
                "email", Objects.requireNonNullElse(user.getEmail(), "(なし)"),
                "name", Objects.requireNonNullElse(user.getFullName(), "(なし)"));
    }

    @GetMapping("/my-orders")
    public String myOrders(@RegisteredOAuth2AuthorizedClient("shop") OAuth2AuthorizedClient client) {
        return orderApi.get()
                .uri("/api/orders")
                .headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
                .retrieve()
                .body(String.class);
    }
}

2つのエンドポイントは異なる種類のトークンを使用しています:

  • OidcUserはIDトークン(およびUserInfoがある場合)から構築されます。これはユーザーが誰であるかを知る正しい方法です。
  • @RegisteredOAuth2AuthorizedClientはregistration shopのアクセストークンを取得します。トークンが期限切れで、リフレッシュトークンがある場合、Springは返す前に自動的に更新します。

ログイン後に/meを呼び出すと、以下が返されます:

{"subject":"customer-42","email":"lan@example.com","name":"Nguyễn Thị Lan"}

注記: Map.ofは値がnullの場合、NullPointerExceptionをスローします。クレームemailはスコープemailを要求し、ユーザーがメールアドレスを持っている場合のみ存在するため、常にクレームが欠けている場合を処理してください。

5. JWTを検証するリソースサーバーの構築

5.1. 依存性と設定

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
server:
  port: 8081
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://localhost:8180/realms/shop
          audiences: order-api

この設定により、Springは各リクエストを次のようにチェックします:

  • issuerのjwks_uriから公開鍵を取得します。
  • 署名とexp/nbfを検証します。
  • issがissuer-uriと一致することを確認します。
  • audにorder-apiが含まれていることを確認します。

audienceチェックを忘れるのは非常に一般的なエラーです。その場合、APIは同じrealm内の別のサービス用に発行されたトークンも受け入れてしまいます。

5.2. スコープに基づく認可

public record Order(String id, String customerId, BigDecimal total) {}
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_orders.read")
                .anyRequest().authenticated())
            .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults()))
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @GetMapping
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        return orderService.findByCustomer(jwt.getSubject());
    }
}

デフォルトでは、Springはクレームscope(またはscp)をSCOPE_プレフィックス付きの権限に変換します。したがって、スコープorders.readはSCOPE_orders.readになります。

なぜCSRFを無効にするのか?このAPIはステートレスで、ヘッダーAuthorizationを通じてのみトークンを受け入れ、cookieを使用しません。したがって、CSRFを無効にするのは適切です。

コントローラーはクエリ文字列からcustomerIdを受け取るのではなく、トークンからsubを取得します。これにより、ユーザーは他の人の注文を表示できません。

リクエスト種別ごとの結果:

トークンなし                      -> 401 Unauthorized
トークンは有効だがスコープ不足    -> 403 Forbidden
トークンが有効でorders.readあり   -> 200 OK + ユーザー自身の注文リスト

注記: Keycloakはroleをrealm_access.rolesに配置し、scopeには含まれません。ロールに基づいて認可を行いたい場合は、そのクレームをGrantedAuthorityにマッピングするカスタムJwtAuthenticationConverterを宣言してください。

6. リソースサーバーのテスト

テストのために実際のKeycloakは必要ありません。ライブラリspring-security-testは、認証済みトークンをシミュレートするpost-processor jwt()を提供しています。

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(ResourceServerConfig.class)
class OrderControllerTest {

    @Autowired
    MockMvcTester mvc;

    @MockitoBean
    OrderService orderService;

    @MockitoBean
    JwtDecoder jwtDecoder; // コンテキスト起動時に実際のissuerを呼ばないようにする

    @Test
    void returnsOrdersOfCurrentCustomer() {
        given(orderService.findByCustomer("customer-42"))
                .willReturn(List.of(new Order("A-1001", "customer-42", new BigDecimal("250000"))));

        assertThat(mvc.get().uri("/api/orders")
                .with(jwt().jwt(j -> j.subject("customer-42"))
                           .authorities(new SimpleGrantedAuthority("SCOPE_orders.read"))))
                .hasStatusOk()
                .bodyJson().extractingPath("$[0].id").isEqualTo("A-1001");
    }

    @Test
    void rejectsTokenWithoutScope() {
        assertThat(mvc.get().uri("/api/orders").with(jwt()))
                .hasStatus(HttpStatus.FORBIDDEN);
    }

    @Test
    void rejectsRequestWithoutToken() {
        assertThat(mvc.get().uri("/api/orders"))
                .hasStatus(HttpStatus.UNAUTHORIZED);
    }
}

これら3つのテストはセクション5.2の3つのブランチをカバーしています:

  • jwt()はデコードステップをスキップします。SecurityContextに直接JwtAuthenticationTokenを配置するため、テストは高速かつ独立しています。
  • authorities(...)を明示的に渡す必要があります。jwt()のデフォルトはSCOPE_readのみであるため、2番目のテストは403を受け取ります。
  • MockMvcTester(Spring Boot 3.4+)により、AssertJを使用してレスポンスに対して直接アサーションを書くことができます。

7. 一般的なエラーと使用してはいけない場合

IDトークンを使用してAPIを呼び出す。 IDトークンのaudはクライアントであり、APIではありません。正しく設定されたリソースサーバーはそれを拒否します。常にアクセストークンを送信してください。

アクセストークンを使用して認証する。 純粋なOAuth 2.0では、アクセストークンは不透明な文字列である可能性があり、ユーザーについて何も含まれません。しかし、クライアントが自身でアクセストークンJWTを解析して識別情報を取得する場合、それは保証されていない形式に依存しています。IDトークンまたはUserInfoエンドポイントを使用してください。

SPAでlocalStorageにトークンを保存する。 単一のXSS脆弱性でトークン全体が露出する可能性があります。より安全なパターンはBFF(Backend for Frontend)です。セクション4のSpringアプリケーションはトークンをサーバー側に保持し、ブラウザはHttpOnlyセッションcookieのみを持ちます。

audを検証しない、またはアクセストークンを長すぎる期間有効にする。 JWTは失効するまで取り消すことはできません。アクセストークンの有効期限を短く保つ(5~15分)し、リフレッシュトークンを使用して延長してください。

issuer-uriが一致しない。 Keycloakがissとしてhttp://keycloak:8080/...(Docker内のホスト名)を返すが、あなたがlocalhost:8180を設定している場合、すべてのトークンがinvalid_tokenエラーで拒否されます。issの値は1文字ごとに正確に一致する必要があります。

注記: 常にOAuth 2.0が必要なわけではありません。monolithアプリケーションが1つあり、フォームログインとサードパーティがAPIを呼び出す必要がない場合は、通常のSpring Securityセッションがより単純で安全です。マシン間通信でユーザーがいない場合は、Authorization Codeではなくclient_credentialsgrantを使用してください。

8. 結論

OAuth 2.0は「このアプリケーションは何ができるか」という質問にアクセストークンで答えます。OIDCは「ユーザーは誰か」という質問にIDトークンで答えます。使用すべきフローはAuthorization Code + PKCEで、stateとnonceが伴います。

Spring Boot 3では、次のことが見られました:

  • spring-boot-starter-oauth2-clientとoauth2Loginはクライアント側のOIDCログイン全体を処理します。
  • spring-boot-starter-oauth2-resource-serverはissuer-uriとaudiencesに従ってJWTを検証します。
  • spring-security-testにより、実際の認可サーバーなしでスコープベースの認可をテストできます。

次のステップとして、JwtAuthenticationConverterを使用してKeycloakのロールをマップするか、クライアントをSPA用のBFFに分離してみることができます。

oauth2openid-connectspring-securityjwtweb-security