Cognito IDトークンの cognito:groups が取得できない — Base64urlデコードの落とし穴
要約
Amazon Cognitoで admin グループに所属しているのに、フロントエンドで cognito:groups が空になる事象が発生。原因はブラウザの atob() がBase64url形式のJWTペイロードを正しくデコードできないケースがあること。ユーザーごとにトークンの長さが変わるため、特定ユーザーだけ再現するのが厄介なポイント。
前提:構成と背景
社内向けのWebアプリケーション(SPA)を以下の構成で構築していました。
[ブラウザ] → [ALB] → [Lambda] → [DynamoDB]
→ [Cognito User Pool]
- 認証は Amazon Cognito User Pool で管理
- フロントエンドは vanilla JS のSPA(S3に配置し、ALB経由で配信)
- ログイン時に Cognito の
InitiateAuth(USER_PASSWORD_AUTH)を呼び、返却されたIDトークンからcognito:groupsを読み取ってUIを出し分ける -
adminグループに所属するユーザーのみ管理画面へのリンクを表示する仕様
事象
管理者用のCognitoグループ admin にユーザーを追加したにも関わらず、特定のユーザーだけ管理画面へのリンクが表示されない。
| ユーザー | adminグループ所属 | 管理リンク表示 |
|---|---|---|
| admin@example.com | 所属 | 表示される |
| another_admin@example.com | 所属 | 表示されない |
- Cognito CLI で
admin-list-groups-for-user→ 両ユーザーともadminグループに所属していることを確認済み - ログアウト → 再ログイン、localStorage クリア → 再ログインを試しても解消しない
- ユーザーを削除して小文字で再作成しても解消しない
切り分け
Cognito側の確認
CLIから直接認証を実行し、発行されるIDトークンのペイロードを確認した。
TOKEN=$(aws cognito-idp initiate-auth \
--client-id ${CLIENT_ID} \
--auth-flow USER_PASSWORD_AUTH \
--auth-parameters USERNAME="another_admin@example.com",PASSWORD="パスワード" \
--region ap-northeast-1 \
--query "AuthenticationResult.IdToken" \
--output text)
echo $TOKEN | python3 -c "
import sys, json, base64
token = sys.stdin.read().strip()
payload = token.split('.')[1]
payload += '=' * (4 - len(payload) % 4)
data = json.loads(base64.urlsafe_b64decode(payload))
print(json.dumps(data, indent=2, ensure_ascii=False))
"
結果:両ユーザーとも cognito:groups: ["admin"] がIDトークンに含まれている。
→ Cognito側は正常。問題はフロントエンドのトークン処理にある。
フロントエンド側の確認
ブラウザの localStorage に保存された currentUser オブジェクトを確認すると:
// admin@example.com でログイン後
{ email: "admin@example.com", groups: ["admin"], ... }
// another_admin@example.com でログイン後
{ email: "another_admin@example.com", groups: [], ... } // ← 空!
IDトークンには cognito:groups が含まれているのに、ブラウザでデコードした結果は空。ここでフロントエンドのデコード処理にバグがあると確定。
原因
問題のコード
// IDトークンからグループ情報を取得
let groups = [];
if (idToken) {
try {
groups = JSON.parse(atob(idToken.split(".")[1]))["cognito:groups"] || [];
} catch {}
}
なぜ失敗するのか
CognitoのIDトークン(JWT)のペイロード部分は Base64url エンコードされている。一方、ブラウザの atob() は 標準Base64 を期待する。
| Base64url | 標準Base64 | |
|---|---|---|
+ の代替 |
- |
+ |
/ の代替 |
_ |
/ |
パディング =
|
なし | あり |
JWTペイロードの長さが4の倍数でないとき、atob() はパディング不足で例外を投げる。しかし catch {} で例外を握り潰しているため、静かに groups = [] のまま処理が進む。
なぜ特定ユーザーだけ再現するのか
IDトークンのペイロードにはユーザー固有の情報(sub(UUID)、email、nickname、jti 等)が含まれる。これらの値の長さはユーザーごとに異なるため、Base64エンコード後の文字列長も変わる。
- パディングなしでも4の倍数になるユーザー →
atob()が正常動作 → 問題に気づかない - 4の倍数にならないユーザー →
atob()が例外 →groupsが空のまま
つまり 「たまたま動くユーザー」と「動かないユーザー」が存在する という再現性の低い厄介なバグになる。
対応
Base64url → 標準Base64 の変換とパディング追加を行ってからデコードする。加えて、マルチバイト文字(日本語ニックネーム等)に対応するためUTF-8デコードも追加。
修正後のコード
// IDトークンからグループ情報を取得
let groups = [];
if (idToken) {
try {
const b64 = idToken.split(".")[1].replace(/-/g, "+").replace(/_/g, "/");
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - b64.length % 4);
const json = decodeURIComponent(
atob(b64 + pad)
.split("")
.map(c => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
);
groups = JSON.parse(json)["cognito:groups"] || [];
} catch {}
}
修正のポイント
-
-→+、_→/: Base64url を標準Base64に変換 -
パディング追加 : 文字列長が4の倍数になるよう
=を補完 -
UTF-8デコード :
atob()の出力をバイト列として扱い、decodeURIComponent+ パーセントエンコードでUTF-8文字列に復元
切り分けの過程(時系列)
この問題は原因特定までに複数のステップを要した。同じ状況に遭遇した方の参考になるよう、切り分けの流れを記録しておく。
- グループ所属の確認 → CLI で確認。問題なし
- ログアウト → 再ログイン → 解消せず
- localStorage クリア → 再ログイン → 解消せず
- User Pool Client 設定確認(ReadAttributes, ExplicitAuthFlows)→ 問題なし
- メールアドレスの大文字/小文字の影響を疑い、小文字で再作成 → 解消せず
-
CLIから直接
initiate-authを実行し、IDトークンのペイロードを確認 →cognito:groupsは含まれていた - → Cognito側は正常、フロントエンドのデコード処理が原因と特定
-
atob()のBase64urlパディング問題を修正 → 解消
ポイントは手順6の「CLIで同じ認証フローを再現してIDトークンの中身を直接確認する」こと。これでCognito側かフロントエンド側かを切り分けられた。
教訓
-
catch {}で例外を黙殺するのは危険。少なくともconsole.warnを入れておけばすぐ気づけた -
JWTのデコードには
atob()を直接使わない。Base64url対応のヘルパー関数を用意すべき - ブラウザ環境なら jwt-decode のような軽量ライブラリを使うのも選択肢
- 「あるユーザーでは動くが別のユーザーでは動かない」場合、ユーザー固有のデータ長に依存する処理を疑う
参考
- RFC 7515 - JSON Web Signature (JWS) Appendix C — Base64url Encoding
- MDN - atob() — 標準Base64のみ対応の旨が記載
- AWS - Using tokens with user pools