From 3361ed29e6f869527846d5a601197dae347e1c92 Mon Sep 17 00:00:00 2001 From: Baris Sencan Date: Sun, 1 Mar 2026 18:38:29 +0000 Subject: [PATCH 1/3] Fix PKCE code verifier not being generated for initial OAuth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When `create_oauth_flow()` is called without an explicit `code_verifier` (i.e. during the initial auth flow in `start_auth_flow()`), the function never sets `autogenerate_code_verifier=True` on the Flow constructor. oauthlib 3.2+ automatically adds `code_challenge` to the authorization URL at the session level, so Google expects a matching `code_verifier` during the token exchange. However, since `Flow.code_verifier` remains `None`, that `None` gets stored in the session store and later passed back during the callback — causing Google to reject the token exchange with `(invalid_grant) Missing code verifier`. The fix adds `autogenerate_code_verifier=True` in the else branch so the Flow object generates and exposes a proper PKCE code verifier that gets stored and reused during the callback token exchange. Co-Authored-By: Claude Opus 4.6 --- auth/google_auth.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/auth/google_auth.py b/auth/google_auth.py index 942d2f5..f9b7947 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -306,6 +306,12 @@ def create_oauth_flow( flow_kwargs["code_verifier"] = code_verifier # Preserve the original verifier when re-creating the flow in callback. flow_kwargs["autogenerate_code_verifier"] = False + else: + # Generate PKCE code verifier for the initial auth flow. + # Without this, oauthlib 3.2+ adds code_challenge to the auth URL + # at the session level, but Flow.code_verifier stays None. + # Google then rejects the token exchange with "Missing code verifier". + flow_kwargs["autogenerate_code_verifier"] = True # Try environment variables first env_config = load_client_secrets_from_env() From ef9c6a9c6955e51f2f9a1dbefee0e2ad874e9c29 Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 1 Mar 2026 17:34:02 -0500 Subject: [PATCH 2/3] make better --- auth/google_auth.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/auth/google_auth.py b/auth/google_auth.py index f9b7947..93e438e 100644 --- a/auth/google_auth.py +++ b/auth/google_auth.py @@ -295,6 +295,7 @@ def create_oauth_flow( redirect_uri: str, state: Optional[str] = None, code_verifier: Optional[str] = None, + autogenerate_code_verifier: bool = True, ) -> Flow: """Creates an OAuth flow using environment variables or client secrets file.""" flow_kwargs = { @@ -308,10 +309,10 @@ def create_oauth_flow( flow_kwargs["autogenerate_code_verifier"] = False else: # Generate PKCE code verifier for the initial auth flow. - # Without this, oauthlib 3.2+ adds code_challenge to the auth URL - # at the session level, but Flow.code_verifier stays None. - # Google then rejects the token exchange with "Missing code verifier". - flow_kwargs["autogenerate_code_verifier"] = True + # google-auth-oauthlib's from_client_* helpers pass + # autogenerate_code_verifier=None unless explicitly provided, which + # prevents Flow from generating and storing a code_verifier. + flow_kwargs["autogenerate_code_verifier"] = autogenerate_code_verifier # Try environment variables first env_config = load_client_secrets_from_env() @@ -526,6 +527,7 @@ def handle_auth_callback( redirect_uri=redirect_uri, state=state, code_verifier=state_info.get("code_verifier"), + autogenerate_code_verifier=False, ) # Exchange the authorization code for credentials From 8463e4fd29a3afcd387da0f2afa7d9c2f41069fb Mon Sep 17 00:00:00 2001 From: Taylor Wilsdon Date: Sun, 1 Mar 2026 17:34:11 -0500 Subject: [PATCH 3/3] auth test --- tests/auth/test_google_auth_pkce.py | 118 ++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/auth/test_google_auth_pkce.py diff --git a/tests/auth/test_google_auth_pkce.py b/tests/auth/test_google_auth_pkce.py new file mode 100644 index 0000000..57e775e --- /dev/null +++ b/tests/auth/test_google_auth_pkce.py @@ -0,0 +1,118 @@ +"""Regression tests for OAuth PKCE flow wiring.""" + +import os +import sys +from unittest.mock import patch + + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))) + +from auth.google_auth import create_oauth_flow # noqa: E402 + + +DUMMY_CLIENT_CONFIG = { + "web": { + "client_id": "dummy-client-id.apps.googleusercontent.com", + "client_secret": "dummy-secret", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + } +} + + +def test_create_oauth_flow_autogenerates_verifier_when_missing(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-1", + ) + + assert flow is expected_flow + args, kwargs = mock_from_client_config.call_args + assert args[0] == DUMMY_CLIENT_CONFIG + assert kwargs["autogenerate_code_verifier"] is True + assert "code_verifier" not in kwargs + + +def test_create_oauth_flow_preserves_callback_verifier(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-2", + code_verifier="saved-verifier", + ) + + assert flow is expected_flow + args, kwargs = mock_from_client_config.call_args + assert args[0] == DUMMY_CLIENT_CONFIG + assert kwargs["code_verifier"] == "saved-verifier" + assert kwargs["autogenerate_code_verifier"] is False + + +def test_create_oauth_flow_file_config_still_enables_pkce(): + expected_flow = object() + with ( + patch("auth.google_auth.load_client_secrets_from_env", return_value=None), + patch("auth.google_auth.os.path.exists", return_value=True), + patch( + "auth.google_auth.Flow.from_client_secrets_file", + return_value=expected_flow, + ) as mock_from_file, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-3", + ) + + assert flow is expected_flow + _args, kwargs = mock_from_file.call_args + assert kwargs["autogenerate_code_verifier"] is True + assert "code_verifier" not in kwargs + + +def test_create_oauth_flow_allows_disabling_autogenerate_without_verifier(): + expected_flow = object() + with ( + patch( + "auth.google_auth.load_client_secrets_from_env", + return_value=DUMMY_CLIENT_CONFIG, + ), + patch( + "auth.google_auth.Flow.from_client_config", + return_value=expected_flow, + ) as mock_from_client_config, + ): + flow = create_oauth_flow( + scopes=["openid"], + redirect_uri="http://localhost/callback", + state="oauth-state-4", + autogenerate_code_verifier=False, + ) + + assert flow is expected_flow + _args, kwargs = mock_from_client_config.call_args + assert kwargs["autogenerate_code_verifier"] is False + assert "code_verifier" not in kwargs