はじめに
Next.js(output: 'export' の SPA=単一ページで動くアプリ)に「Google でサインイン」を付けた。認証基盤は Amazon Cognito、その Hosted UI 経由で Google にログインさせる。ログインボタンだけは自前 UI にして、Amplify の <Authenticator> は使わない。SDK は aws-amplify v6(6.17.0)。
ところが、Google 認証自体は成功して /callback まで戻ってくる。なのに画面は「サインイン処理中…」のまま永久に固まる。success にも error にもならない。原因は2段階あった。本稿はその原因と直し方を、OAuth の用語から噛み砕いて残す。
この記事を貫くテーマは、次のとおり。
-
「code を受け取る」と「code をトークンに交換する」は別物:
fetchAuthSessionは取り出すだけ、交換はリスナーの仕事。 -
Amplify v6 の自前 UI ではリスナーを自分で有効化する:
enable-oauth-listenerの import が必須。加えてtrailingSlash: trueの 308 が交換のきっかけを奪う。
1. 前提知識:そもそも何が起きているのか(OAuth の流れ)
「Google でログイン」の裏側は OAuth 2.0 Authorization Code Flow + PKCE(PKCE=横取りされた code を他人に使われないようにする拡張)。難しそうだが、“合鍵を直接渡さず、引換券を経由して受け取る” と考えると分かりやすい。
| 登場人物 | 役割 |
|---|---|
| 自分のアプリ(SPA) | ログインボタンと、戻り先の /callback ページを持つ |
| Cognito Hosted UI | AWS の認証窓口。Google との仲介もしてくれる |
| 実際にユーザー本人確認をする所 | |
| Amplify(SDK) | 上記とのやり取りをフロント側で代行するライブラリ |
肝は ★のトークン交換。/callback?code=... の code はただの引換券で、これ単体ではログイン完了にならない。裏でトークンに交換する処理が走って初めてログイン成功になる。今回の不具合は、この★が走らないことが2段階の原因で起きていた。
2. 実装の全体像(実際のコード)
サインイン開始(sign-in/page.tsx)
'use client';
import { signInWithRedirect } from 'aws-amplify/auth';
import { configureAmplify } from '@/lib/amplify';
const SignInPage = () => {
useEffect(() => { configureAmplify(); }, []);
const handleGoogleSignIn = () => {
void signInWithRedirect({ provider: 'Google' }); // → Hosted UI へ飛ぶ
};
// ...ボタン
};
Amplify の設定(lib/amplify.ts / auth-config.ts)
env から Cognito の値を読んで Amplify.configure する純関数。responseType: 'code'(= Authorization Code Flow)、scope は openid email profile。
コールバック(callback/page.tsx)
戻ってきた /callback?code=... で、セッションが取れたら成功表示。
const session = await fetchAuthSession();
const idToken = session.tokens?.idToken;
if (idToken !== undefined) { setStatus('success'); }
ここまでは一見正しい。だが
fetchAuthSessionの役割を誤解していると、この後で固まる。
3. ハマり①:trailingSlash: true の 308 リダイレクトでトークン交換が消える
最初の症状は「/callback に戻った瞬間に固まる」。next.config.mjs に trailingSlash: true が入っていた。
そもそも trailingSlash とは
trailingSlash は Next.js の設定で、URL の末尾にスラッシュ(/)を付けるかどうかを決める。trailingSlash: true にすると、Next.js は末尾スラッシュなしの URL を、ありの URL へ強制リダイレクトする。
-
trailingSlash: false(デフォルト):/callbackはそのまま/callback -
trailingSlash: true:/callbackにアクセスすると/callback/へ 308 リダイレクトされる
なぜわざわざ付けるのか。理由は静的ホスティングとの相性だ。output: 'export'(静的書き出し)で S3 / CloudFront に置くとき、/callback/index.html というディレクトリ構造の URL に揃えたい。そのために true にしていることが多い。つまりそれ自体は珍しくない、ごく普通の設定だ。だからこそ気づきにくい。
ちなみに 308 は「Permanent Redirect(恒久的リダイレクト)」を表す HTTP ステータス。301 と似ているが、308 はリダイレクト後も元の HTTP メソッドとボディを維持する点が違う。ブラウザは黙って新しい URL へ飛び直す。
何が問題だったか
Google から戻ってくる URL は /callback?code=...&state=...。ところが trailingSlash: true だと、これがページとして読まれる前に /callback/?code=... へ 308 で飛ばされる。
問題はこの「飛ばされる」タイミングにある。Amplify はページ読み込み時に、URL の code を見てトークン交換を始める。だが 308 で別 URL に飛び直す一拍のあいだに、そのきっかけを取り逃がす。結果、交換が走らないまま固まる。
直し方
trailingSlash: true を外す(意図をコメントで残す)。
// OAuth コールバックを /callback で一致させるため末尾スラッシュは付けない(PB-10)。
// trailingSlash:true だと /callback → /callback/ に 308 され、Amplify のコード交換が走らず
// 「サインイン処理中…」で止まる。
// trailingSlash: true, ← 削除
※ Cognito 側のコールバック URL 許可リストと末尾スラッシュの有無まで一致させること。
/callbackと/callback/は別物として扱われる。
これで一歩進んだ……が、まだ固まった。ここからが本番。
4. ハマり②(本命):enable-oauth-listener の import が抜けていた
症状の整理
まず、何が起きていて何が起きていないのかを切り分ける。
- URL は正しく
/callback?code=...&state=...まで戻っている(= Google も Cognito も成功)。 - なのに
successにもerrorにもならず、ずっとpending。
つまり ★のトークン交換が一切走っていない。
なぜ走らないのか — Amplify v6 の内部挙動
ここが今回いちばん理解が要った所。fetchAuthSession() は 「もう保存済みのトークンを取り出す」だけの関数で、URL の ?code= を見て交換する処理ではない。
では誰が交換するのか。v6 では、URL に code があるのを検知して交換する処理は専用の OAuth リスナーが担当する。そして自前 UI(<Authenticator> を使わない)の場合、そのリスナーは勝手には登録されない。明示的に副作用 import して有効化する:
import 'aws-amplify/auth/enable-oauth-listener';
そもそも enable-oauth-listener とは
aws-amplify/auth/enable-oauth-listener は、aws-amplify v6 が用意している**「OAuth リスナーを登録するためだけのサブモジュール」。読み込むと OAuth リスナーが有効化される**。このリスナーが signInWithRedirect の戻り(?code=... 付きでページに戻ってきた状態)を監視し、自動でトークン交換を始める。
なぜ v6 でわざわざ別 import になったのか。v5 までは Auth を設定した時点でこのリスナーが自動で登録されていた。v6 ではライブラリがモジュール分割された。使わない機能を最終バンドルから削れるようにする、いわゆるツリーシェイキングのためだ。その結果、OAuth リスナーは「使う人だけ明示的に有効化する」方式に変わった。<Authenticator> コンポーネントはこの import を内部で済ませてくれる。だから自前 UI のときだけ、自分で書く必要がある。
「副作用 import」とは:
import { foo } from 'x'のように値を受け取らない import のこと(import 'x'の形)。これは「xから何かを使うため」ではなく、「xが読み込まれた瞬間に走るトップレベルの処理(=副作用)を起こすため」に書く。ここでは読み込まれた瞬間にリスナー登録が実行されるのが、その副作用にあたる。
この import が無いと、次の連鎖で pending に落ちる。
たとえ話:引換券(code)を握って受付(callback ページ)まで来たのに、「引換券→商品の交換窓口の担当者(OAuth リスナー)」をそもそも出勤させていなかった。
import 'aws-amplify/auth/enable-oauth-listener'は、その担当者を出勤させる一行。
直し方
コールバックページの先頭で副作用 import するだけ。
'use client';
// 自前 UI で signInWithRedirect の戻り(?code=)を完了させるには
// この副作用 import が必須。無いとトークン交換リスナーが登録されず「サインイン処理中…」で固まる。
import 'aws-amplify/auth/enable-oauth-listener';
import { fetchAuthSession } from 'aws-amplify/auth';
// ...
テストの注意:副作用 import(読み込んだ瞬間にリスナー登録が走る)なので、単体テストでは実体を読み込ませないようモックする。
vi.mock('aws-amplify/auth/enable-oauth-listener', () => ({}))
5. 「なぜこれで成功したのか」総まとめ
直したのは結局2行(+設定1箇所)。効いた理由を一言で:
-
trailingSlashを外した → 戻りの URL が 308 で飛ばされず、/callback?code=...のままページが読まれる(交換の“きっかけ”を取り逃がさない)。 -
enable-oauth-listenerを import した → URL のcodeを検知して交換する担当者を出勤させた。交換が走り、Hub(Amplify がログイン等の出来事を通知する仕組み)のsignedInが発火し、fetchAuthSession()が idToken を返し、「サインインしました」に到達した。
要するに 「code を受け取ること」と「code をトークンに交換すること」は別物だ。v6 の自前 UI では、後者を自分で有効化する必要があった。これが核心である。
6. 検証時にハマりやすい点
直し終えても、再現テストの段階で別の罠を踏みやすい。先に潰しておく。
-
URL の
codeはワンタイム(一度使うと無効/短時間で失効)。失敗した/callback?code=...をリロードしても直らない。必ず/sign-inからやり直す。 - Cognito のコールバック URL 許可リストと、アプリの実 URL(末尾スラッシュ含め)を一致させる。
- 複数 origin(
localhostとdev.app等)を許可する場合は env をカンマ区切りで配列化する。
おわりに
「Google でログイン」が固まったとき、つい fetchAuthSession の呼び方を疑ってしまう。だが本当の境目は 「code を受け取る」と「code を交換する」が別の仕事だという点にある。Amplify v6 の自前 UI では、交換を担うリスナーを自分で出勤させる必要がある。
- 「code を受け取る」と「code をトークンに交換する」は別物
-
v6 の自前 UI ではリスナーを自分で有効化する(+
trailingSlash308 の罠)
同じ「サインイン処理中…」で固まっている人は、まず enable-oauth-listener の import と trailingSlash を確認してほしい。次の一歩としては、Hub の認証イベント購読や、サインアウト・トークン更新まわりの実装に進むとよい。