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 の設定
- Google Cloud Console にアクセスし、OAuth 2.0 クライアント ID を発行します。
- 種類は Web アプリケーション を選びます。
- 承認済みリダイレクト URI に
http://localhost:3000を設定しておきます。 - 発行された クライアント ID を控えます。
アプリケーションの設定
Spring Boot 側で JWT を扱うために、次の依存関係を追加します。
spring-boot-starter-oauth2-resource-server を導入すると、JWT 検証用の機能が使用できます。自前で JWT のパース・署名検証を行う必要がなくなります。
implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server")
アプリケーション設定にクライアントIDを追加します。
google.client-id=発行したクライアントID
実装
フロントエンド(Next.js)
Google Identity Services を使い、サインインボタンを表示します。
ユーザーがログインすると、credential として署名付き ID トークンが返ってきます。
"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 は誰でもアクセス可能にし、それ以外は認証を要求する設定です。
@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) をチェックして「自分のアプリ向けに発行されたトークンかどうか」を確認します。
@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 でチェックします。
@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
フロントから送られてきたトークンをデコードし、クレームを確認します。
@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);
});
// 実際はメールアドレス等を使用して認証を行う
}
}
実行の流れ
- ユーザーがフロントエンドで「Google ログイン」ボタンを押す
- Google Identity Service が ID トークンを発行
- フロントエンドがトークンを Spring Boot に送信
- バックエンドで Google の公開鍵を使って署名検証
-
aud(audience) が一致しているかチェック - クレーム(
email,subなど)を取得して利用
まとめ
- フロントエンドでは Google Identity Services を利用してトークンを取得
- バックエンドでは Spring Security の NimbusJwtDecoder でトークン検証
-
audを検証することで、他アプリ用のトークンを誤って受け入れないようにする
実運用では、この後に自分のアプリ用の JWT を発行して返す実装を追加し、以降の API リクエストはその JWT を使って認証するのが一般的です。
こうすることでバックエンド内部を Google に依存させず、認証の責務を明確に分離できます。
Discussion