🔐

Spring Security で Google 認証を行う方法

に公開

はじめに

Webアプリに「Googleでログイン」ボタンを導入したいことはよくあります。
Spring Boot と Next.js を組み合わせて、Google の ID トークンを検証する仕組みを最小限のコードで実装します。

ポイントは次の3つです。

  • フロントエンドで Google Identity Services (GIS) を使い、IDトークンを取得
  • バックエンド(Spring Boot)で IDトークンを検証
  • aud (audience) を確認して、正しいクライアント ID 向けのトークンかチェック

準備

Google Cloud の設定

  1. Google Cloud Console にアクセスし、OAuth 2.0 クライアント ID を発行します。
  2. 種類は Web アプリケーション を選びます。
  3. 承認済みリダイレクト URI に http://localhost:3000 を設定しておきます。
  4. 発行された クライアント ID を控えます。

アプリケーションの設定

Spring Boot 側で JWT を扱うために、次の依存関係を追加します。
spring-boot-starter-oauth2-resource-server を導入すると、JWT 検証用の機能が使用できます。自前で JWT のパース・署名検証を行う必要がなくなります。

build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")

アプリケーション設定にクライアントIDを追加します。

application.properties
google.client-id=発行したクライアントID

実装

フロントエンド(Next.js)

Google Identity Services を使い、サインインボタンを表示します。
ユーザーがログインすると、credential として署名付き ID トークンが返ってきます。

page.tsx
"use client";

import { useEffect } from 'react'

export default function Home() {
  useEffect(() => {
    // Google Identity Services を初期化
    window.google?.accounts.id.initialize({
      client_id: process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID!, // 環境変数からクライアントIDを指定
      callback: handleCredentialResponse, // ログイン後のコールバック
    })
    // Google ログインボタンを描画
    window.google?.accounts.id.renderButton(
      document.getElementById('google-signin-button'),
      { theme: 'outline', size: 'large' }
    )
  }, [])

  // Google から返却された ID トークンを Spring Boot に送信
  const handleCredentialResponse = async (response: any) => {
    const token = response.credential
    const res = await fetch('http://localhost:8080/api/login/google', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    })
  }

  return (
    <div>
      <h1>Google ログイン</h1>
      <div id="google-signin-button"></div>
      <script src="https://accounts.google.com/gsi/client" async defer></script>
    </div>
  );
}

バックエンド(Spring Boot)

SecurityConfig

/api/login/google は誰でもアクセス可能にし、それ以外は認証を要求する設定です。

SecurityConfig.java
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtDecoder googleJwtDecoder;

    @Bean
    public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
        return http
            .cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS を有効化
            .csrf(AbstractHttpConfigurer::disable) // API 用なので CSRF は無効化
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/login/google").permitAll() // ログインAPIは認証不要
                .anyRequest().authenticated() // それ以外は認証必須
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt.decoder(googleJwtDecoder)) // JWT デコーダーを使用
            )
            .build();
    }
}

GoogleJwtConfig

Google の公開鍵を使って署名を検証します。Google の公開鍵は定期的にローテーションされるため、「JWK Set URI」を指定して自動更新に対応します。
さらに aud (audience) をチェックして「自分のアプリ向けに発行されたトークンかどうか」を確認します。

GoogleJwtConfig.java
@Configuration
public class GoogleJwtConfig {

    @Bean
    public JwtDecoder googleJwtDecoder(@Value("${google.client-id}") String clientId) {
        // Google の公開鍵エンドポイントを指定してデコーダーを作成
        NimbusJwtDecoder decoder = NimbusJwtDecoder
            .withJwkSetUri("https://www.googleapis.com/oauth2/v3/certs")
            .build();

        // aud (audience) を検証する Validator を追加
        OAuth2TokenValidator<Jwt> audienceValidator = new AudienceValidator(clientId);
        decoder.setJwtValidator(
            new DelegatingOAuth2TokenValidator<>(JwtValidators.createDefault(), audienceValidator)
        );

        return decoder;
    }
}

AudienceValidator

aud クレームが一致しているかどうかを検証します。複数の値を保つ場合があるため contains でチェックします。

AudienceValidator.java
@RequiredArgsConstructor
public class AudienceValidator implements OAuth2TokenValidator<Jwt> {

    private final String clientId;

    @Override
    public OAuth2TokenValidatorResult validate(Jwt jwt) {
        List<String> audiences = jwt.getAudience();
        // クライアントIDが一致すれば成功
        if (audiences.contains(clientId)) {
            return OAuth2TokenValidatorResult.success();
        }
        // 一致しない場合は失敗
        OAuth2Error error = new OAuth2Error(
            OAuth2ErrorCodes.INVALID_TOKEN,
            "The required audience is missing",
            null
        );
        return OAuth2TokenValidatorResult.failure(error);
    }
}

Controller

フロントから送られてきたトークンをデコードし、クレームを確認します。

Controller.java
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/login")
public class Controller {

    private final JwtDecoder googleJwtDecoder;

    @PostMapping("/google")
    public void googleLogin(@RequestBody Map<String, String> request) {
        // フロントから送られた ID トークンをデコード
        Jwt decodedJwt = googleJwtDecoder.decode(request.get("token"));

        // 必要なクレームを取得
        System.out.println("sub: " + decodedJwt.getSubject());
        System.out.println("iss: " + decodedJwt.getIssuer());
        System.out.println("aud: " + decodedJwt.getAudience());
        System.out.println("exp: " + decodedJwt.getExpiresAt());
        System.out.println("iat: " + decodedJwt.getIssuedAt());
        System.out.println("email: " + decodedJwt.getClaimAsString("email"));

        // 全クレームを表示
        decodedJwt.getClaims().forEach((k, v) -> {
            System.out.println(k + ": " + v);
        });

        // 実際はメールアドレス等を使用して認証を行う
    }
}

実行の流れ

  1. ユーザーがフロントエンドで「Google ログイン」ボタンを押す
  2. Google Identity Service が ID トークンを発行
  3. フロントエンドがトークンを Spring Boot に送信
  4. バックエンドで Google の公開鍵を使って署名検証
  5. aud (audience) が一致しているかチェック
  6. クレーム(email, sub など)を取得して利用

まとめ

  • フロントエンドでは Google Identity Services を利用してトークンを取得
  • バックエンドでは Spring Security の NimbusJwtDecoder でトークン検証
  • aud を検証することで、他アプリ用のトークンを誤って受け入れないようにする

実運用では、この後に自分のアプリ用の JWT を発行して返す実装を追加し、以降の API リクエストはその JWT を使って認証するのが一般的です。
こうすることでバックエンド内部を Google に依存させず、認証の責務を明確に分離できます。

Discussion