From 898ccfd667065937ad86331528a424a8d5e7aa88 Mon Sep 17 00:00:00 2001 From: xiahu88988 Date: Fri, 17 Apr 2026 07:24:19 +0800 Subject: [PATCH] fix(skills): honor scope query from Google OAuth redirect URL Parse scope from the raw callback URL before stripping the auth code so Flow.fetch_token matches user-granted scopes. Add regression test for dual-scope callbacks. Made-with: Cursor --- .../google-workspace/scripts/setup.py | 21 +++++++------------ tests/skills/test_google_oauth_setup.py | 16 ++++++++++++++ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index 851d8911b6..ac48b65c7c 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -289,6 +289,7 @@ def exchange_auth_code(code: str): sys.exit(1) pending_auth = _load_pending_auth() + raw_callback = code code, returned_state = _extract_code_and_state(code) if returned_state and returned_state != pending_auth["state"]: print("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.") @@ -298,19 +299,13 @@ def exchange_auth_code(code: str): from google_auth_oauthlib.flow import Flow from urllib.parse import parse_qs, urlparse - # Extract granted scopes from the callback URL if present - if returned_state and "scope" in parse_qs(urlparse(code).query if isinstance(code, str) and code.startswith("http") else {}): - granted_scopes = parse_qs(urlparse(code).query)["scope"][0].split() - else: - # Try to extract from code_or_url parameter - if isinstance(code, str) and code.startswith("http"): - params = parse_qs(urlparse(code).query) - if "scope" in params: - granted_scopes = params["scope"][0].split() - else: - granted_scopes = SCOPES - else: - granted_scopes = SCOPES + # Extract granted scopes from the callback URL if the user pasted the full redirect URL. + granted_scopes = list(SCOPES) + if isinstance(raw_callback, str) and raw_callback.startswith("http"): + params = parse_qs(urlparse(raw_callback).query) + scope_val = (params.get("scope") or [""])[0].strip() + if scope_val: + granted_scopes = scope_val.split() flow = Flow.from_client_secrets_file( str(CLIENT_SECRET_PATH), diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index 0e1fe6d7f8..a7908bd76a 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -177,6 +177,22 @@ class TestExchangeAuthCode: flow = FakeFlow.created[-1] assert flow.fetch_token_calls == [{"code": "4/extracted-code"}] + def test_passes_scopes_from_redirect_url_to_flow(self, setup_module): + """Callback URL carries space-delimited scope list; Flow must receive it (not full SCOPES).""" + setup_module.PENDING_AUTH_PATH.write_text( + json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"}) + ) + g1 = "https://www.googleapis.com/auth/gmail.readonly" + g2 = "https://www.googleapis.com/auth/calendar" + from urllib.parse import quote + + scope_q = quote(f"{g1} {g2}", safe="") + setup_module.exchange_auth_code( + f"http://localhost:1/?code=4/extracted-code&state=saved-state&scope={scope_q}" + ) + flow = FakeFlow.created[-1] + assert flow.scopes == [g1, g2] + def test_rejects_state_mismatch(self, setup_module, capsys): setup_module.PENDING_AUTH_PATH.write_text( json.dumps({"state": "saved-state", "code_verifier": "saved-verifier"})