はじめに
個人開発のWebアプリにGoogleアカウントでのログインと、Googleカレンダー連携を実装した際、認証フローに潜む数々の「仕様」という名の罠にハマり、数日を溶かしました。この記事は、特にリフレッシュトークンが取得できない、連携状態が正しく反映されないといった、Google OAuthで誰もが一度は通る道のりと、その解決策をまとめたものです。
アプリの構成
- フロントエンド: React (TypeScript)
- バックエンド: Node.js (Express)
- 認証ライブラリ: googleapis
遭遇した認証関連の罠と解決策
罠1: redirect_uri_mismatch
- 全てはここから始まった
Google連携実装の登竜門、redirect_uri_mismatch
エラー。私も例に漏れず遭遇しました。
- 症状: Googleの認証画面で、「エラー 400: redirect_uri_mismatch」が表示される。
- 原因の誤解: エラーメッセージに表示されているURLは「間違っているURL」だと思い込み、Google Cloudコンソールに登録したURLと見比べて「どこが違うんだ?」と悩んでいました。
-
本当の原因: エラーメッセージに表示されている
redirect_uri
は、「あなたのアプリがGoogleにリクエストした、本来あるべき正しいURL」そのものでした。問題は、Google Cloudコンソール側の登録内容が、その正しいURLと微妙に異なっていたことでした。 -
解決策: エラーメッセージに表示されている
redirect_uri
の値を一字一句コピーし、Google Cloudコンソールの「承認済みのリダイレクト URI」に完全に一致するように貼り付けて修正しました。
教訓:
redirect_uri_mismatch
エラーが出たら、エラーメッセージこそが正義。そこに表示されているURLを信じてコンソールを修正すべし。
罠2: リフレッシュトークンが一度しか発行されない地獄
今回のデバッグで最も時間を費やしたのが、このリフレッシュトークン問題です。まず前提として、Google OAuthには2種類のトークンが存在します。
- アクセストークン: APIを叩くための短期的な利用券。有効期限が短い(通常1時間)。
- リフレッシュトークン: 新しいアクセストークンを再取得するための長期的な鍵。
このリフレッシュトークンが、最初の1回しか取得できませんでした。
-
症状: ユーザーが認証を繰り返しても、DBの
google_refresh_token
カラムがnull
のまま更新されない。 - 原因: これはGoogle OAuthの重要な仕様です。リフレッシュトークンは、ユーザーがそのアプリを一番最初に認証した時にしか発行されません。2回目以降の認証では、アクセストークンのみが発行されます。
- 解決策: 開発中に何度もリフレッシュトークンを再取得するため、開発環境でのみ、Googleに対して「毎回、ユーザーに同意画面を再表示してほしい」と明示的に要求するパラメータを追加しました。
// 認証URLのオプションを定義
const options = {
access_type: 'offline', // リフレッシュトークン取得に必須
scope: scopes,
state: state
};
// 開発環境の場合のみ、毎回同意画面を表示させるパラメータを追加
if (process.env.NODE_ENV === 'development') {
options.prompt = 'consent';
}
const url = oauth2Client.generateAuthUrl(options);
教訓: リフレッシュトークンが欲しければ、
prompt: 'consent'
を唱えよ。ただし、本番環境で使うとユーザーに毎回同意を求めることになるため、開発中のみに限定するのが鉄則。
罠3: 連携状態が画面に反映されない(クライアントとサーバーの認識のズレ)
シークレットモードでは正常に動作するのに、通常モードでは連携状態が更新されない、という奇妙な現象に悩まされました。
-
症状: Google認証後、アプリにリダイレクトされても画面上は「未連携」のまま。しかし、ページをスーパーリロード(
Ctrl+F5
)すると正常に「連携済み」と表示される。 - 原因: サーバー側ではDBが更新され「連携済み」になっているのに、ブラウザ側(React)が連携前の古いユーザー情報(state)を持ち続けていたためです。
-
解決策: サーバーとクライアントで、状態を同期させる仕組みを実装しました。
-
サーバー側: 認証成功後、リダイレクトURLに成功を示すクエリパラメータ(例:
?calendar_connect_success=true
)を付与する。 -
クライアント側: ページ読み込み時に、このパラメータを検知する
useEffect
を追加。パラメータが存在すれば、APIで最新のユーザー情報を再取得し、Reactのstateを更新する。
-
サーバー側: 認証成功後、リダイレクトURLに成功を示すクエリパラメータ(例:
// Google認証からのリダイレクトを処理する
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('calendar_connect_success') === 'true') {
toast.success('Googleカレンダーとの連携が完了しました!');
// ① APIを叩いて最新のユーザー情報を取得し、stateを更新
refetchUser();
// ② URLからパラメータを消して画面をクリーンにする
window.history.replaceState({}, document.title, window.location.pathname);
}
}, []); // このeffectはページ読み込み時に一度だけ実行
教訓: 外部サービスとの連携後は、クライアントが持つstateが古くなっている可能性を常に疑う。リダイレクト後には、必ずサーバーから最新の状態を取得し直すのが確実な解決策。
まとめ
Google OAuthの認証フローは、一見シンプルに見えて、その裏には多くの「仕様」が隠されています。今回のデバッグを通して、以下の3つの重要性を再認識しました。
- 公式ドキュメントを読む: リフレッシュトークンの仕様はドキュメントに明記されています。急がば回れ、です。
- クライアントとサーバーの状態同期を意識する: SPAでは、リダイレクト後にクライアントの状態が古くなっていることを前提に設計する必要があります。
-
開発を楽にするパラメータを知る:
prompt: 'consent'
のような、開発効率を上げるための「おまじない」を知っていると、デバッグが格段にスムーズになります。
この記事が、同じようにGoogle認証の沼で苦しんでいる方の助けになれば幸いです。