django-oauth-toolkit 2.0 の client_secret ハッシュ化で外部連携が壊れた話
TL;DR
django-oauth-toolkit を 1.x から 2.0 にアップグレードすると、Application.client_secret が 平文からハッシュ値に自動変換 される。この変更に気づかず、DB 上のハッシュ値を「シークレット」として外部サービスにコピーすると、二重ハッシュ で認証が通らなくなる。さらに、Application を動的に生成するコードがある場合、バージョンアップ後に平文を返すべき箇所でハッシュ値を返してしまう問題も起きる。
背景
2つの Django サービス間で OAuth2 Client Credentials Grant による認証を行っていた。
| サービス | 役割 | django-oauth-toolkit |
|---|---|---|
| Service A (リソースサーバー) | ファイル配信 API を提供 | 2.4.0 |
| Service B (クライアント) | API からファイルを取得 | 1.7.1 |
Service B は Service A の OAuth2 トークンエンドポイントに HTTP Basic Auth で client_id:client_secret を送信し、アクセストークンを取得してからファイルをダウンロードする。
Service B Service A
| |
|-- POST /o/token/ --------------->|
| Authorization: Basic base64( |
| client_id:client_secret) |
| |-- client_secret をハッシュ化
| |-- DB のハッシュ値と比較
|<-- access_token -----------------|
| |
|-- GET /api/files/ -------------->|
| Authorization: Bearer token |
|<-- file data --------------------|
このフローは数年間安定稼働していた。
何が起きたか
ある月次バッチ処理で、Service B が Service A からファイルを取得できなくなった。
エラーログ(Service B 側)
oauthlib.oauth2.rfc6749.errors.InvalidClientError: (invalid_client)
Service A のトークンエンドポイントが invalid_client を返している。client_id は正しいので、client_secret の不一致が原因。
DB を確認
| |
同じ値が入っている。 一見正しそうに見えるが、これが問題だった。
根本原因
django-oauth-toolkit 2.0 の Breaking Change
django-oauth-toolkit 2.0.0(2022-04-24 リリース)で、Application.client_secret の保存方式が変更された:
Changed to implement hashed client_secret values. This is a breaking change that will migrate all your existing cleartext application.client_secret values to be hashed with Django’s default password hashing algorithm and cannot be reversed.
マイグレーション 0006_alter_application_client_secret が実行されると:
- DB 上の全 Application の
client_secretが 平文 → ハッシュ値 に変換される - Django のパスワードハッシュアルゴリズム(デフォルト:
pbkdf2_sha256)が使われる - この変換は不可逆 — 元の平文は二度と取得できない
時系列で見る障害の経緯
[v1.x 時代] 両サービスとも平文で client_secret を保持
Service A DB: client_secret = "my-super-secret-key"
Service B DB: client_secret = "my-super-secret-key"
→ 認証成功 ✅
[Service A を v2.0+ にアップグレード]
マイグレーション 0006 が自動実行
Service A DB: client_secret = "pbkdf2_sha256$260000$...hashed..."
Service B DB: client_secret = "my-super-secret-key"
→ 認証成功 ✅(B が平文を送信 → A がハッシュ化して比較)
[ある時点で credentials の再設定が必要になった]
管理者が Service A の DB から client_secret をコピー
Service A DB: client_secret = "pbkdf2_sha256$260000$...hashed..."
Service B DB: client_secret = "pbkdf2_sha256$260000$...hashed..." ← ハッシュ値をコピー!
→ 認証失敗 ❌
なぜ失敗するか
Service B が "pbkdf2_sha256$260000$...hashed..." を HTTP Basic Auth で送信すると、Service A はそれを さらにハッシュ化 して DB の値と比較する。
Service B が送信: "pbkdf2_sha256$260000$...hashed..."
Service A が計算: hash("pbkdf2_sha256$260000$...hashed...")
= "pbkdf2_sha256$260000$...DIFFERENT..."
DB に保存済み: "pbkdf2_sha256$260000$...hashed..."
→ 不一致 → InvalidClientError
つまり 「ハッシュのハッシュ」 が生成され、当然一致しない。
修正方法
1. 新しい client_secret を生成
| |
2. Service A(リソースサーバー)にハッシュ値を保存
| |
| |
または Django Admin から Application を開いて Secret を入力すれば、保存時に自動ハッシュ化される。
3. Service B(クライアント)に平文を保存
| |
要点
| サービス | 保存する値 |
|---|---|
| OAuth2 Provider(DOT 2.0+) | ハッシュ値(自動 or make_password()) |
| OAuth2 Client(外部サービス) | 平文 |
次に壊れるタイミング: クライアント側も DOT 2.0 にアップグレードしたとき
今回の修正で Service B のクレデンシャルテーブルには平文が入っている。しかし Service B が将来 DOT を 1.x → 2.0+ にアップグレードすると、別の問題が起きる可能性がある。
ケース 1: Service B 自身が OAuth2 Provider も兼ねている場合
Service B が他のクライアント(例: モバイルアプリ、他のバッチサービス)に対して OAuth2 Provider として機能している場合、DOT のマイグレーション 0006 が Service B の oauth2_provider_application テーブルのシークレットをすべてハッシュ化する。
[Service B を DOT 2.0+ にアップグレード]
migrate 実行 → 0006_alter_application_client_secret
→ Service B の oauth2_provider_application.client_secret が全てハッシュ化
⚠️ Service B に接続していたクライアント(モバイルアプリ等)が
平文シークレットを持っていれば → 認証成功 ✅
しかしハッシュ値をコピーして設定していたら → 認証失敗 ❌(同じ罠の再発)
対策: アップグレード前に、Service B の oauth2_provider_application に接続している全クライアントが平文のシークレットを保持していることを確認する。
ケース 2: Application を動的に生成するコードがある場合(重要)
これがより危険なケースだ。Service B のコードベースに、デバイス登録等で Application を動的に作成する処理があった:
| |
DOT 1.x では app.client_secret は平文が返る。しかし DOT 2.0 以降では save() 時にハッシュ化される ため、app.client_secret にはハッシュ値が入っている。このハッシュ値をクライアントに返してしまうと、クライアントはハッシュ値をシークレットとして使い、認証時に二重ハッシュが発生して失敗する。
[DOT 1.x]
Application.save() → client_secret = "plaintext123"
return app.client_secret → "plaintext123" ← 平文 ✅
[DOT 2.0+]
Application.save() → client_secret = "pbkdf2_sha256$260000$..." (ハッシュ化)
return app.client_secret → "pbkdf2_sha256$260000$..." ← ハッシュ値! ❌
修正方法: save() の前に平文を控えておく:
| |
ケース 3: Service B のクレデンシャルテーブルが DOT とは独立している場合
Service B が service_credentials のような独自テーブル(Django の CharField で平文保存)に client_secret を持っている場合、DOT のマイグレーション 0006 はこのテーブルに影響しない。
[Service B を DOT 2.0+ にアップグレード]
migrate 実行 → 0006 は oauth2_provider_application のみ対象
→ service_credentials テーブルは変更なし
→ Service A への認証は継続して成功 ✅
ただし、将来的にクレデンシャル管理コードを「DOT の Application モデルに統一しよう」とリファクタリングする場合は、ケース 2 の罠に注意。
事前に防ぐ方法
1. アップグレード前にシークレットを控える
マイグレーション 0006 を実行する 前に、全 Application の client_secret を平文で記録しておく。
| |
マイグレーション後は平文を取得する手段がなくなる。
2. ドキュメントに「平文 vs ハッシュ」を明記する
外部サービスとの OAuth2 連携ドキュメントに、以下を明記しておく:
⚠️ DOT 2.0+ では DB 上の client_secret はハッシュ値です。
外部サービスに設定するのは平文のシークレットです。
DB の値をそのままコピーしないでください。
3. Application を動的生成するコードを洗い出す
Application.objects.create() / get_or_create() を使っている箇所を検索し、save() 後に client_secret(ハッシュ値)をクライアントに返していないか確認する:
| |
該当箇所では、save() 前に平文をキャプチャしてクライアントに返すよう修正が必要。
4. 接続テストを自動化する
CI/CD やヘルスチェックで OAuth2 トークン取得テストを定期実行する:
| |
5. Secrets Manager を使う
client_secret を DB に直接保存するのではなく、AWS Secrets Manager や HashiCorp Vault で管理すれば:
- 平文を安全に保存できる
- アクセス履歴が残る
- 「DB からコピー」という事故が起きにくい
6. DOT のバージョンを揃える(可能なら)
Provider と Client で DOT のバージョンが大きく異なると、こうした認識のズレが生じやすい。同一プロジェクトグループ内のサービスは、なるべくバージョンを揃えておく。
ただし「揃える」際にクライアント側も 2.0 にアップグレードする場合は、ケース 2(動的 Application 生成) に該当しないか必ず確認すること。
まとめ
| 項目 | 内容 |
|---|---|
| 何が起きたか | DOT 2.0 アップグレードでハッシュ化された secret を外部サービスにコピーし、認証が壊れた |
| 根本原因 | DB 上の値が平文からハッシュに変わったことに気づかなかった |
| エラー | InvalidClientError (invalid_client) — 二重ハッシュによる不一致 |
| 修正 | 新しい secret を生成し、Provider にはハッシュ、Client には平文を設定 |
| 次の爆弾 | クライアント側の DOT アップグレード時に、動的 Application 生成コードがハッシュ値を返す |
| 予防 | マイグレーション前に平文を控える / Application 動的生成コードの洗い出し / 接続テスト自動化 |
壊れるタイミングの整理
| タイミング | 壊れる条件 | 影響 |
|---|---|---|
| Provider 側を DOT 2.0 にアップグレード | DB のハッシュ値をクライアントにコピー | InvalidClientError(二重ハッシュ) |
| Client 側を DOT 2.0 にアップグレード | Application を動的生成して client_secret を返すコードがある | デバイス/クライアントがハッシュ値を受け取り認証失敗 |
| Client 側を DOT 2.0 にアップグレード | クレデンシャルが独自テーブル(DOT 管理外)に保存 | 影響なし ✅ |
django-oauth-toolkit 2.0 の client_secret ハッシュ化は、Django のパスワード管理と同じ思想で正しいセキュリティ改善。ただし、外部サービスとの連携がある場合、「DB に入っている値 = 設定すべき値」ではなくなるという点を見落としやすい。
特に、長期間安定稼働していたサービス間連携で「今まで動いていたから大丈夫」という前提があると、credentials 再設定時やバージョンアップ時にハマりやすい落とし穴になる。
References: