概要
個人開発のある株式分析Webアプリで、認証基盤を「Django allauth + Simple JWT のメール/パスワード認証」から「AWS Cognito User Pool ベースの OAuth 2.0 / OIDC」へ移行した。この記事は、その設計判断と実装のうち、「Django標準の User モデルを一切壊さずにマネージド認証へ寄せる」 ことを軸にまとめたものだ。
移行の核心は次の3点に集約される。
-
auth_userにカラムを足さず、別テーブルでCognitoのsub↔user_idを1:1で紐付ける - 既存ユーザーは JIT(Just-In-Time)プロビジョニングで自動保全する
- DRFのAuthentication Classをハイブリッド化し、旧クライアントを動かしたまま段階移行する
既存ユーザーのデータ(ポートフォリオ・チャット履歴)を1件も壊さないことが絶対条件だったため、「一気に置き換える」選択肢は最初から捨てている。
環境
- 移行前: Django + Django REST Framework、Django標準
Userモデル(カスタム不採用)、allauth + Simple JWT のメール/パスワード認証(access 30分 / refresh 7日 / rotation有) - 移行後: AWS Cognito User Pool を単一の認証基盤に。ブラウザSPA / 外部LLM連携 / 既存の静的Bearerキークライアント の3経路を集約
- トークン: JWT (RS256) / JWKS
- 実行環境: バックエンドは AWS Lambda(ステートレス実行)
- IaC: Terraform(User Pool / App Client / Google IdP連携を定義)
発生した問題(=なぜ移行したか)
メール/パスワード認証は動いていたが、次の3つの要求に応えられなくなっていた。
- 外部LLMサービスの連携要求: あるLLMサービスのAgent/Actions連携の利用規約が、OAuth認証経路を要求してきた。自前のメール/パスワード+独自トークンでは接続できない。
- 利用者の即時無効化: 離脱者・退職者を運営側から即座にログイン不可にしたい。自前実装だとトークン失効の設計を自分で背負うことになる。
- 認証経路の分散: ブラウザSPA / 外部LLM連携 / 既存の静的Bearerキークライアント と、認証の入口がバラバラに増えつつあった。1つの認証基盤に集約したい。
Cognito User Pool はこの3つをまとめて解決できる。OIDC準拠でOAuthの入口を持ち、Disable user / Sign out user で即時無効化でき、複数App Clientで経路を分けられる。
原因(設計上の落とし穴)— なぜ標準Userを壊してはいけないか
Cognito移行と聞くと、まず思いつくのは「auth_user に cognito_sub カラムを足してカスタムUserモデルにする」だ。だがDjangoでカスタムUserモデルへ移行するのは、稼働中のプロジェクトでは屈指のハイリスク作業だ。
-
AUTH_USER_MODELの差し替えはマイグレーション履歴に深く食い込む - 既存の外部キー(ポートフォリオ・チャット履歴など)が全て
Userを指しているため、テーブル差し替えは連鎖する - allauth / admin / サードパーティが標準
User前提で動いている
つまり「認証の入口を変えたい」だけなのに、データモデルの根幹に手を入れることになる。ここでの設計判断は明快だ。
標準
Userは不可侵にする。CognitoのsubとDjangoのuser_idの対応は、別テーブルに逃がす。
対応方法
1. リンクテーブルで sub ↔ user_id を1:1に紐付ける
auth_user には触れない。別テーブルを1枚立てて、そこにIdP側のアイデンティティを持たせる。
from django.conf import settings
from django.db import models
class IdentityLink(models.Model):
"""Cognito(等IdP)のアイデンティティと Django User を 1:1 で結ぶ。
auth_user には一切カラムを足さない。"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="identity_link",
)
provider = models.CharField(max_length=32, default="cognito")
subject = models.CharField(max_length=255, unique=True) # Cognitoのsub
email = models.EmailField()
last_signed_in_at = models.DateTimeField(null=True, blank=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["provider", "subject"],
name="uq_provider_subject",
)
]
このテーブルにした理由は2つ。
-
標準モデルの不可侵化:
UserはDjangoのものとして完全に温存できる。ロールバックも「リンクテーブルを消すだけ」で済む。 -
将来の拡張: 1ユーザーが複数IdP(Cognito / Google / 将来の別プロバイダ)を持つ拡張を考えると、
providerを軸にした関連は最初から別テーブルが自然。OneToOneをForeignKeyに緩めれば1:Nへ広げられる。
2. JITプロビジョニングで既存ユーザーを自動保全する
移行時に既存ユーザーへ一括で sub を割り当てるバッチは書かない。初回ログイン時に、その場で紐付ける(JIT)。解決順序が肝で、必ず次の順で解く。
from django.contrib.auth import get_user_model
from django.utils import timezone
User = get_user_model()
def resolve_user_from_claims(claims: dict) -> User:
"""Cognito access token の検証済みclaimsから Django User を解決する。
①sub検索 → ②emailマッチング → ③新規作成 の順。"""
sub = claims["sub"]
email = claims.get("email", "").lower()
# ① 既存リンクを sub で検索(2回目以降はここで即ヒット)
link = IdentityLink.objects.filter(
provider="cognito", subject=sub
).select_related("user").first()
if link:
link.last_signed_in_at = timezone.now()
link.save(update_fields=["last_signed_in_at"])
return link.user
# ② email で既存ユーザーを引き当てる(=移行前ユーザーの初回ログイン)
# email_verified は検証層で担保済みである前提
user = User.objects.filter(email__iexact=email).first()
# ③ どこにも居なければ新規作成(Cognito経由のみログイン可にする)
if user is None:
user = User.objects.create(
username=email,
email=email,
)
user.set_unusable_password() # ローカルパスワードでのログインを封じる
user.save()
IdentityLink.objects.create(
provider="cognito",
subject=sub,
email=email,
user=user,
last_signed_in_at=timezone.now(),
)
return user
ポイントは②のemailマッチングだ。移行前ユーザーはCognitoに事前登録(またはGoogle連携で初回サインイン)した後、初めてこのアプリにアクセスした瞬間に、emailで既存 User へ吸着される。既存のポートフォリオやチャット履歴は User に紐付いたままなので、1件も欠けない。
新規作成時に set_unusable_password() を呼ぶのは重要だ。これで「Cognito経由でしかログインできないユーザー」になり、旧来のパスワードログイン経路と混線しない。
注意: emailマッチングによる自動吸着は、
email_verifiedが保証されている前提でのみ安全に成立する。未検証メールで吸着を許すと、他人のアカウントを乗っ取れてしまう。検証は次節のトークン検証層で必ず担保する。
3. DRF認証をハイブリッド化して段階移行する
認証を一晩で切り替えるのは怖い。既存の静的Bearerキークライアントが動かなくなる。そこで、DRFの DEFAULT_AUTHENTICATION_CLASSES に新旧2つを並べ、順番に試す。
from rest_framework.authentication import BaseAuthentication
from rest_framework import exceptions
class CognitoJWTAuthentication(BaseAuthentication):
"""Authorization: Bearer <JWT> を Cognito access token として検証する。"""
def authenticate(self, request):
raw = _extract_bearer(request)
if raw is None:
return None # 次のAuthentication Classへ委譲
# JWTでなさそう(サービス固有プレフィックス付きキー)なら委譲
if raw.startswith("svc_key_"):
return None
claims = verify_cognito_access_token(raw) # 検証は後述
user = resolve_user_from_claims(claims) # JIT解決
return (user, claims)
class LegacyStaticKeyAuthentication(BaseAuthentication):
"""既存の静的Bearerキー(サービス固有プレフィックス付き)を認証する。
旧クライアントを無改修で動かし続けるための後方互換層。"""
def authenticate(self, request):
raw = _extract_bearer(request)
if raw is None or not raw.startswith("svc_key_"):
return None
user = lookup_user_by_static_key(raw)
if user is None:
raise exceptions.AuthenticationFailed("invalid key")
return (user, None)
# settings.py
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"myapp.auth.CognitoJWTAuthentication", # 新方式を先に
"myapp.auth.LegacyStaticKeyAuthentication", # 旧方式にフォールバック
],
}
authenticate() が None を返すとDRFは次のクラスへフォールバックする。キー形式のプレフィックスで振り分けることで、CognitoのJWTなら新方式、svc_key_... なら旧方式、と自然に分岐する。旧クライアントは一切改修せず動き続け、その裏で新経路を立ち上げられる。旧経路の廃止は移行完了後、後述の410 Goneで最後に落とす。
4. JWT検証層 — チェックすべきclaimを妥協しない
トークン検証を甘くすると、上の②emailマッチングが凶器に変わる。Cognitoのaccess tokenで最低限確認するclaimは以下。
import jwt # PyJWT
ALLOWED_CLIENT_IDS = {"<spa_client_id>", "<confidential_client_id>"}
ISSUER = "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_XXXXXXXXX"
def verify_cognito_access_token(raw: str) -> dict:
header = jwt.get_unverified_header(raw)
key = get_signing_key(header["kid"]) # JWKSから取得(次節)
claims = jwt.decode(
raw,
key=key,
algorithms=["RS256"],
issuer=ISSUER,
options={"require": ["exp", "iss", "token_use"]},
)
# issuer 完全一致(decodeでも検証されるが明示)
if claims.get("iss") != ISSUER:
raise exceptions.AuthenticationFailed("bad issuer")
# access token であること(id token を誤って受けない)
if claims.get("token_use") != "access":
raise exceptions.AuthenticationFailed("not an access token")
# 許可した App Client からのトークンだけ通す
if claims.get("client_id") not in ALLOWED_CLIENT_IDS:
raise exceptions.AuthenticationFailed("client_id not allowed")
# メール乗っ取り防止:未検証メールは吸着させない
if claims.get("email_verified") is False:
raise exceptions.AuthenticationFailed("email not verified")
return claims
- issuer完全一致: 別User Poolのトークンを弾く
- token_use=access: id tokenをaccess tokenの代わりに受けない
- client_id許可リスト: 想定外のApp Clientを排除
- 有効期限: 当然チェック
-
email_verified=falseを拒否: これがJITの②を安全にする最後の砦。未検証メールでの吸着を許すと他人のアカウントを乗っ取れる
5. JWKSキャッシュ — Lambdaでもローカルメモリで十分
RS256の検証には公開鍵(JWKS)が要る。毎リクエストで取りに行くのは遅い。かといってキーローテーションには追従したい。
方針は「TTL 1hのローカルメモリキャッシュ + kid不一致時の強制再取得」。
_JWKS_CACHE = {"keys": {}, "fetched_at": 0}
_TTL = 3600
def get_signing_key(kid: str):
now = _now()
# TTL切れ、または未知のkid(=ローテーション直後)は取り直す
if (now - _JWKS_CACHE["fetched_at"] > _TTL) or (kid not in _JWKS_CACHE["keys"]):
_JWKS_CACHE["keys"] = _fetch_jwks() # /.well-known/jwks.json
_JWKS_CACHE["fetched_at"] = now
key = _JWKS_CACHE["keys"].get(kid)
if key is None:
raise exceptions.AuthenticationFailed("unknown kid")
return key
kidが未知なら即再取得するのがローテーション追従の勘所だ。Cognitoが鍵を回してもkid不一致をトリガに新しいJWKSを引き直せる。Lambdaはステートレスだが、コンテナは一定時間生存する。その生存期間中はこのローカルメモリキャッシュがヒットするので、実用上Redisのような外部キャッシュは当面不要だった。
6. App Clientを2つに分ける
Cognitoの App Client は、経路ごとに分けた。
| 用途 | 種別 | secret | PKCE | callback URL |
|---|---|---|---|---|
| ブラウザSPA | public client | なし | 必須 | https://app.example.com/callback |
| 外部LLM連携 | confidential client | あり | — | 連携サービス側のURL |
分ける理由は明確だ。
- secret漏洩防止: SPAはブラウザにコードが露出するので、secretを持てない(public + PKCE一択)。外部連携はサーバ間でsecretを安全に保持できる(confidential)。1つのApp Clientで兼ねると、どちらかが必ず不適切になる。
- callback URL分離: SPAと外部連携で戻り先が全く異なる。同居させるとリダイレクト先の管理が緩くなり、オープンリダイレクトの温床になる。
- ライフサイクル/監査の独立: 外部連携のsecretだけローテーションする、片方だけ失効させる、といった運用を独立に回せる。
実装例(利用者の即時無効化)
マネージド認証に寄せた最大の実利がこれだ。離脱者・退職者が出たら、Cognito側の操作だけで全経路を同時に遮断できる。
-
Disable user: 即座に新規ログイン不可になる。ただし既発行のaccess tokenは有効期限(最長1h)まで生存する。 -
Sign out user: refresh tokenも無効化され、トークンの更新が止まる。
この2つで、ブラウザSPA / 外部LLM連携 / 静的キークライアントの全経路が同時に閉じる。自前実装だと各経路ごとに失効ロジックを書く羽目になっていた。
確認方法
-
既存ユーザー保全: 移行前ユーザーで初回ログインし、ポートフォリオ・チャット履歴が欠けずに紐付くこと。
IdentityLinkにsubが1行作られ、Userは同一idのまま(=新規作成されていない)ことを確認 - JIT解決順序: 2回目以降のログインが①のsub検索で即ヒットし、emailマッチングまで到達しないこと
-
ハイブリッド認証: 旧クライアント(
svc_key_...)が無改修で200を返し、Cognito JWTでも同一エンドポイントが通ること -
claim検証:
token_use=idのトークン、別issuer、未許可client_id、email_verified=falseがそれぞれ401になること - キーローテーション: 未知kidのトークンでJWKS再取得が走り、検証が通ること
-
即時無効化:
Disable user後に新規ログイン不可、Sign out user後にrefreshが失敗すること
注意点
-
emailマッチングは
email_verified前提でのみ安全。検証層で未検証メールを弾く実装を必ずセットにする。これが崩れるとアカウント乗っ取りになる -
Disable userは既発行access tokenを即座には殺さない(最長1h残る)。「即時」の粒度を運用側で正しく認識しておく - ハイブリッド認証は移行完了までの時限措置。旧経路は最後に410 Goneで明示的に廃止し、後方互換を惰性で残さない
- 段階移行の順序は「インフラ準備(Terraform)→ バックエンド認証層 → フロント改修(Amplify Auth)→ 旧認証廃止(410 Gone)+ 外部連携接続」。フロントを触る前にバックエンドで新旧両対応にしておくのが、ダウンタイムを作らないコツ
まとめ
- Django標準の
Userを壊さずCognitoへ移行する鍵は、auth_userに触らず別テーブルでsub↔user_idを1:1に紐付けること - 既存ユーザーは**JIT(sub検索 → emailマッチング → 新規作成)**で初回ログイン時に自動保全でき、データを1件も壊さない
- DRFのAuthentication Classをハイブリッド化すれば、旧クライアントを動かしたまま段階移行できる
- App Clientはpublic(SPA/PKCE)とconfidential(外部連携/secret)で分ける
- JWKSはkid不一致時の強制再取得でローテーションに追従。Lambdaでもローカルメモリキャッシュで十分実用
「認証の入口を変えたい」だけなら、データモデルの根幹に手を入れる必要はない。壊さない境界をどこに引くかが、移行の成否を分けた。