diff --git a/skills/productivity/google-workspace/scripts/google_api.py b/skills/productivity/google-workspace/scripts/google_api.py index 5289539aad..6504c098ba 100644 --- a/skills/productivity/google-workspace/scripts/google_api.py +++ b/skills/productivity/google-workspace/scripts/google_api.py @@ -47,6 +47,13 @@ SCOPES = [ ] +def _normalize_authorized_user_payload(payload: dict) -> dict: + normalized = dict(payload) + if not normalized.get("type"): + normalized["type"] = "authorized_user" + return normalized + + def _ensure_authenticated(): if not TOKEN_PATH.exists(): print("Not authenticated. Run the setup script first:", file=sys.stderr) @@ -170,7 +177,12 @@ def get_credentials(): creds = Credentials.from_authorized_user_file(str(TOKEN_PATH), _stored_token_scopes()) if creds.expired and creds.refresh_token: creds.refresh(Request()) - TOKEN_PATH.write_text(creds.to_json()) + TOKEN_PATH.write_text( + json.dumps( + _normalize_authorized_user_payload(json.loads(creds.to_json())), + indent=2, + ) + ) if not creds.valid: print("Token is invalid. Re-run setup.", file=sys.stderr) sys.exit(1) diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py index 7b5d351f88..0477749d7b 100755 --- a/skills/productivity/google-workspace/scripts/gws_bridge.py +++ b/skills/productivity/google-workspace/scripts/gws_bridge.py @@ -19,6 +19,13 @@ def get_token_path() -> Path: return get_hermes_home() / "google_token.json" +def _normalize_authorized_user_payload(payload: dict) -> dict: + normalized = dict(payload) + if not normalized.get("type"): + normalized["type"] = "authorized_user" + return normalized + + def refresh_token(token_data: dict) -> dict: """Refresh the access token using the refresh token.""" import urllib.error @@ -55,7 +62,9 @@ def refresh_token(token_data: dict) -> dict: tz=timezone.utc, ).isoformat() - get_token_path().write_text(json.dumps(token_data, indent=2)) + get_token_path().write_text( + json.dumps(_normalize_authorized_user_payload(token_data), indent=2) + ) return token_data diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index cb8c38cb98..bf4fb39caf 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -60,6 +60,13 @@ REQUIRED_PACKAGES = ["google-api-python-client", "google-auth-oauthlib", "google REDIRECT_URI = "http://localhost:1" +def _normalize_authorized_user_payload(payload: dict) -> dict: + normalized = dict(payload) + if not normalized.get("type"): + normalized["type"] = "authorized_user" + return normalized + + def _load_token_payload(path: Path = TOKEN_PATH) -> dict: try: return json.loads(path.read_text()) @@ -151,7 +158,12 @@ def check_auth(): if creds.expired and creds.refresh_token: try: creds.refresh(Request()) - TOKEN_PATH.write_text(creds.to_json()) + TOKEN_PATH.write_text( + json.dumps( + _normalize_authorized_user_payload(json.loads(creds.to_json())), + indent=2, + ) + ) missing_scopes = _missing_scopes_from_payload(_load_token_payload(TOKEN_PATH)) if missing_scopes: print(f"AUTHENTICATED (partial): Token refreshed but missing {len(missing_scopes)} scopes:") @@ -313,7 +325,7 @@ def exchange_auth_code(code: str): sys.exit(1) creds = flow.credentials - token_payload = json.loads(creds.to_json()) + token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json())) # Store only the scopes actually granted by the user, not what was requested. # creds.to_json() writes the requested scopes, which causes refresh to fail diff --git a/tests/skills/test_google_oauth_setup.py b/tests/skills/test_google_oauth_setup.py index 89612b7df8..445ed82de0 100644 --- a/tests/skills/test_google_oauth_setup.py +++ b/tests/skills/test_google_oauth_setup.py @@ -160,7 +160,9 @@ class TestExchangeAuthCode: assert flow.state == "saved-state" assert flow.code_verifier == "saved-verifier" assert flow.fetch_token_calls == [{"code": "4/test-auth-code"}] - assert json.loads(setup_module.TOKEN_PATH.read_text())["token"] == "access-token" + saved = json.loads(setup_module.TOKEN_PATH.read_text()) + assert saved["token"] == "access-token" + assert saved["type"] == "authorized_user" assert not setup_module.PENDING_AUTH_PATH.exists() def test_extracts_code_from_redirect_url_and_checks_state(self, setup_module): diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index 655f32f52b..bbd51a35df 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -100,6 +100,7 @@ def test_bridge_refreshes_expired_token(bridge_module, tmp_path): # Verify persisted saved = json.loads(token_path.read_text()) assert saved["token"] == "ya29.refreshed" + assert saved["type"] == "authorized_user" def test_bridge_exits_on_missing_token(bridge_module): @@ -182,3 +183,54 @@ def test_api_calendar_list_respects_date_range(api_module): params = json.loads(cmd[params_idx + 1]) assert params["timeMin"] == "2026-04-01T00:00:00Z" assert params["timeMax"] == "2026-04-07T23:59:59Z" + + +def test_api_get_credentials_refresh_persists_authorized_user_type(api_module, monkeypatch): + token_path = api_module.TOKEN_PATH + _write_token(token_path, token="ya29.old") + + class FakeCredentials: + def __init__(self): + self.expired = True + self.refresh_token = "1//refresh" + self.valid = True + + def refresh(self, request): + self.expired = False + + def to_json(self): + return json.dumps({ + "token": "ya29.refreshed", + "refresh_token": "1//refresh", + "client_id": "123.apps.googleusercontent.com", + "client_secret": "secret", + "token_uri": "https://oauth2.googleapis.com/token", + }) + + class FakeCredentialsModule: + @staticmethod + def from_authorized_user_file(filename, scopes): + assert filename == str(token_path) + assert scopes == api_module.SCOPES + return FakeCredentials() + + google_module = types.ModuleType("google") + oauth2_module = types.ModuleType("google.oauth2") + credentials_module = types.ModuleType("google.oauth2.credentials") + credentials_module.Credentials = FakeCredentialsModule + transport_module = types.ModuleType("google.auth.transport") + requests_module = types.ModuleType("google.auth.transport.requests") + requests_module.Request = lambda: object() + + monkeypatch.setitem(sys.modules, "google", google_module) + monkeypatch.setitem(sys.modules, "google.oauth2", oauth2_module) + monkeypatch.setitem(sys.modules, "google.oauth2.credentials", credentials_module) + monkeypatch.setitem(sys.modules, "google.auth.transport", transport_module) + monkeypatch.setitem(sys.modules, "google.auth.transport.requests", requests_module) + + creds = api_module.get_credentials() + + saved = json.loads(token_path.read_text()) + assert isinstance(creds, FakeCredentials) + assert saved["token"] == "ya29.refreshed" + assert saved["type"] == "authorized_user"