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 を確認

1
2
3
4
5
6
7
-- Service A(リソースサーバー / DOT 2.4.0)
SELECT client_secret FROM oauth2_provider_application WHERE client_id = 'xxx';
-- → 'pbkdf2_sha256$260000$ycGMzaLP9BBs...$Yq5N...'

-- Service B(クライアント / 独自テーブルに credentials を保持)
SELECT client_secret FROM service_credentials WHERE client_id = 'xxx';
-- → 'pbkdf2_sha256$260000$ycGMzaLP9BBs...$Yq5N...'

同じ値が入っている。 一見正しそうに見えるが、これが問題だった。

根本原因

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 が実行されると:

  1. DB 上の全 Application の client_secret平文 → ハッシュ値 に変換される
  2. Django のパスワードハッシュアルゴリズム(デフォルト: pbkdf2_sha256)が使われる
  3. この変換は不可逆 — 元の平文は二度と取得できない

時系列で見る障害の経緯

[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 を生成

1
2
3
4
5
from django.utils.crypto import get_random_string

new_secret = get_random_string(50)
print(f"新しい平文シークレット: {new_secret}")
# → "aB3xK9mP2qR7wT5yU8..."(この値を控えておく)

2. Service A(リソースサーバー)にハッシュ値を保存

1
2
3
4
from django.contrib.auth.hashers import make_password

hashed = make_password(new_secret)
# → "pbkdf2_sha256$260000$..."
1
2
3
UPDATE oauth2_provider_application
SET client_secret = 'pbkdf2_sha256$260000$...'
WHERE client_id = 'xxx';

または Django Admin から Application を開いて Secret を入力すれば、保存時に自動ハッシュ化される。

3. Service B(クライアント)に平文を保存

1
2
3
UPDATE service_credentials
SET client_secret = 'aB3xK9mP2qR7wT5yU8...'
WHERE client_id = 'xxx';

要点

サービス保存する値
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 を動的に作成する処理があった:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# DOT 1.x では問題ない
def register_device(user, identifier):
    defaults = {
        "client_type": "confidential",
        "authorization_grant_type": "client-credentials",
    }
    app, created = Application.objects.get_or_create(
        user=user,
        name=f"device-{user.id}-{identifier}",
        defaults=defaults,
    )
    # クライアントに返す
    return {"client_id": app.client_id, "client_secret": app.client_secret}

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() の前に平文を控えておく:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def register_device(user, identifier):
    defaults = {
        "client_type": "confidential",
        "authorization_grant_type": "client-credentials",
    }
    app, created = Application.objects.get_or_create(
        user=user,
        name=f"device-{user.id}-{identifier}",
        defaults=defaults,
    )

    if created:
        # DOT 2.0+: save() 後の client_secret はハッシュ値
        # 平文は generate_client_secret() で新規生成するか、
        # save() 前にキャプチャしておく必要がある
        from oauth2_provider.generators import generate_client_secret
        plain_secret = generate_client_secret()
        app.client_secret = plain_secret  # save() でハッシュ化される
        app.save()
        return {"client_id": app.client_id, "client_secret": plain_secret}
    else:
        # 既存アプリ: 平文は取得不可。再発行が必要
        raise ValueError("Device already registered. Re-registration required for secret.")

ケース 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 を平文で記録しておく。

1
2
3
4
-- マイグレーション実行前に実行
SELECT id, name, client_id, client_secret
FROM oauth2_provider_application;
-- → この時点では平文が表示される

マイグレーション後は平文を取得する手段がなくなる。

2. ドキュメントに「平文 vs ハッシュ」を明記する

外部サービスとの OAuth2 連携ドキュメントに、以下を明記しておく:

⚠️ DOT 2.0+ では DB 上の client_secret はハッシュ値です。
   外部サービスに設定するのは平文のシークレットです。
   DB の値をそのままコピーしないでください。

3. Application を動的生成するコードを洗い出す

Application.objects.create() / get_or_create() を使っている箇所を検索し、save() 後に client_secret(ハッシュ値)をクライアントに返していないか確認する:

1
grep -rn "Application.objects" --include="*.py" | grep -E "create|get_or_create"

該当箇所では、save() 前に平文をキャプチャしてクライアントに返すよう修正が必要。

4. 接続テストを自動化する

CI/CD やヘルスチェックで OAuth2 トークン取得テストを定期実行する:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# healthcheck.py
import requests

def check_oauth2_connectivity():
    response = requests.post(
        "https://service-a.example.com/o/token/",
        data={"grant_type": "client_credentials"},
        auth=("client_id", "plaintext_secret"),  # 環境変数から取得
    )
    assert response.status_code == 200, f"OAuth2 token error: {response.text}"

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: