google-auth-oauthlib で invalid_grant|PKCEのcode_verifierが別のFlowに渡っていなかった


運営者がブログ運用のために作っているツール(Python製)は、AdSenseの数字をAPIで取得しています。その認証をやり直す機能で、毎回 invalid_grant になって先へ進めなくなったことがあります。このサイトの記事の作り方とツールの関係は運営者情報にまとめています。

起きていたこと

2026年8月23日の記録です。ツールの画面から再認証を始めると、Googleの許可画面までは進みます。許可してツールへ戻ってきたところで「認証情報の取得に失敗しました」と出て止まりました。

Googleが返していたのは invalid_grant でした。リダイレクトURI、クライアントの設定、state の照合はどれも正しいことを確かめたうえでの失敗です。

invalid_grant というと、保存しているリフレッシュトークンが切れた、と読みたくなります。ですが今回は、認可コードをトークンへ交換する最初の段階で失敗していました。PKCE を定めた RFC 7636 の4.6には、検証用の値が一致しなければ invalid_grant を返すと書かれています。

If the values are equal, the token endpoint MUST continue processing
as normal (as defined by OAuth 2.0 [RFC6749]).  If the values are not
equal, an error response indicating "invalid_grant" as described in
Section 5.2 of [RFC6749] MUST be returned.

原因:開始と戻りで Flow が別だった

ツールは google-auth-oauthlib の Flow を使っています。再認証は「許可画面のURLを作る」リクエストと、「戻ってきた認可コードを交換する」リクエストの2回に分かれていて、それぞれで Flow を作っていました。

インストールされているバージョン(1.4.0)のソースを読むと、authorization_url() の中で code_verifier が無ければ128文字の値を作り、そのハッシュを code_challenge として認可URLへ付けています。

if self.code_verifier is None and self.autogenerate_code_verifier:
    chars = ascii_letters + digits + "-._~"
    rnd = SystemRandom()
    random_verifier = [rnd.choice(chars) for _ in range(0, 128)]
    self.code_verifier = "".join(random_verifier)

autogenerate_code_verifier の既定は True なので、何も指定しなくてもPKCEが付きます。交換する fetch_token() のほうは、自分の code_verifier をそのまま送ります。

kwargs.setdefault("code_verifier", self.code_verifier)

つまり、URLを作った Flow にだけ code_verifier があり、戻りで新しく作った Flow には無い状態でした。Googleは code_challenge を受け取っているのに、交換時には検証用の値が届かないので拒否します。ライブラリのドキュメントの authorization_url() の説明には、別の Flow でトークンを取るなら state を指定する必要がある、とは書かれています。code_verifier については触れられておらず、ソースを読んで分かりました。

ネットワークに出ずに再現する

2026年10月19日に、Googleへは接続せずに確かめました。クライアントIDと秘密鍵は dummy.apps.googleusercontent.com のようなダミーです。トークンの交換は requests_oauthlib の OAuth2Session.fetch_token を差し替え、送ろうとした code_verifier を記録するだけにしています。

from unittest import mock
from urllib.parse import urlparse, parse_qs
from google_auth_oauthlib.flow import Flow
import requests_oauthlib

CONFIG = {"installed": {
    "client_id": "dummy.apps.googleusercontent.com",
    "client_secret": "dummy-secret",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
}}
SCOPES = ["https://www.googleapis.com/auth/adsense.readonly"]
REDIRECT = "http://localhost:8080/callback"

def make(**kw):
    return Flow.from_client_config(CONFIG, scopes=SCOPES, redirect_uri=REDIRECT, **kw)

sent = {}
def fake_fetch_token(self, token_url, **kwargs):
    sent["code_verifier"] = kwargs.get("code_verifier")
    return {"access_token": "x", "token_type": "Bearer"}

# 1) 開始時の Flow A
a = make()
url, state = a.authorization_url(prompt="consent")

# 2) 戻りで作り直した Flow B(直す前の形)
with mock.patch.object(requests_oauthlib.OAuth2Session, "fetch_token", fake_fetch_token):
    b = make(state=state)
    b.fetch_token(code="dummy-code")

# 3) 直した後:A の値を保存しておき、戻りの Flow に渡す
saved = a.code_verifier
with mock.patch.object(requests_oauthlib.OAuth2Session, "fetch_token", fake_fetch_token):
    c = make(state=state, autogenerate_code_verifier=False)
    c.code_verifier = saved
    c.fetch_token(code="dummy-code")

実際のスクリプトでは、各段階で値を表示しています。出力は次のとおりです。

google-auth-oauthlib 1.4.0
A 生成直後  code_verifier: None
A 認可URL後 code_verifier: 長さ 128 文字
認可URLの code_challenge_method: ['S256']
認可URLに code_challenge がある: True
認可URLに code_verifier がある : False

B 生成直後  code_verifier: None
B が送ろうとした code_verifier: None

修正後 送ろうとした code_verifier が A と同じ: True
B' で作り直した verifier が A と同じ: False

A は authorization_url() を呼んだ時点で128文字の code_verifier を持ち、認可URLには code_challenge と S256 が付きます。code_verifier そのものはURLに載りません。B は作った直後から None のままで、交換でも None を渡そうとしています。これが invalid_grant の正体でした。

最後の行は、戻り側で authorization_url() を呼び直しても、別の値ができるだけで役に立たないことを示しています。

差し替える場所にも注意が要ります。Flow.fetch_token を丸ごと差し替えると、code_verifier を載せる処理そのものを飛ばしてしまい、直す前のコードでもテストが通ります。ツールの回帰テストも OAuth2Session.fetch_token の側を差し替えています。

直し方

許可画面のURLを作ったときに code_verifier を state と一緒に取っておき、戻りで作る Flow に渡します。

# 開始時
auth_url, state = flow.authorization_url(prompt="consent", state=state)
pending[state] = {"code_verifier": flow.code_verifier, ...}

# 戻り
p = pending.pop(state)
flow = Flow.from_client_secrets_file(path, scopes=scopes, redirect_uri=redirect_uri,
                                     state=state, autogenerate_code_verifier=False)
flow.code_verifier = p["code_verifier"]
flow.fetch_token(code=code)

ツールでは、この値をメモリだけに持ち、ディスクにもログにも書きません。ツールを再起動すれば消えるので、そのときは認証を最初からやり直してもらいます。state と同じく、1回使ったら捨てます。

あわせて直したこと

  • 交換の失敗をすべて「取得に失敗しました」にまとめず、Googleが返した error から「コードが使用済み」「期限切れ」「検証情報が失われた」などに分けて表示するようにしました
  • Googleが要求より広いスコープを返すことがあり、そのままだと oauthlib が例外にします。交換の間だけ OAUTHLIB_RELAX_TOKEN_SCOPE を立て、交換の後で必要なスコープが本当に付いたかを確かめて、足りなければ保存しないようにしました
  • 認可コードがアクセスログに平文で残っていたので、伏せ字にしました

なお、リフレッシュトークンが数日で切れる問題は、これとは別に観測しています。原因はまだ特定できていないので、ここでは扱いません。

自分のコードで確かめること

  • 認可URLを作る処理とコードを交換する処理で、同じ Flow を使っているか。別なら code_verifier を渡しているか
  • invalid_grant が出たとき、それが交換時(認可コード)なのか更新時(リフレッシュトークン)なのかを分けて見ているか
  • テストで Flow.fetch_token を丸ごと差し替えていないか
  • code_verifier や認可コードがログに出ていないか

検証した環境

この記事の調査・再現・執筆は、運営者の指示のもとでAIエージェント(AnthropicのClaude)が行い、公開するかどうかは運営者が決めています。載せた出力は、そのAIエージェントがこのMacで実際にコマンドを実行して得たものです。

  • macOS 26.6.2
  • Python 3.12.13
  • google-auth-oauthlib 1.4.0 / requests-oauthlib 2.0.0 / oauthlib 3.3.1
  • 再現は2026年10月19日。Googleへの通信はしていません(ダミーの設定と差し替えのみ)

トップへ戻る