{title}
{detail}
diff --git a/README.md b/README.md index 8524626..5ddb4af 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,18 @@ Pull-mode posture: the agent lists, searches, reads, and **drafts** — it never ## Architecture `auth.py` is a service-agnostic OAuth-refresh + REST core; one module per service (`gmail.py`, `calendar.py`). Adding Drive/Docs/Sheets is a new module + tools on the same core. -## Config -`google.client_id` / `client_secret` / `refresh_token` (or `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` / `GOOGLE_REFRESH_TOKEN`). **Mint the refresh token with the full scope set you intend to grow into** (e.g. `gmail.modify`, `calendar`, `drive`, `documents`, `spreadsheets`) so adding a service needs no re-consent. +## Connect (one-click OAuth) +Set `google.client_id` + `client_secret` in **Settings ▸ Plugins ▸ Google**, open the **Google** panel, hit **Connect Google**, approve on Google's consent screen — done. The plugin runs the authorization-code flow itself (public callback at `/plugins/google/oauth/callback`, gated by a single-use state nonce) and writes the refresh token into the untracked `secrets.yaml`; it takes effect immediately, no restart. + +One-time Google Cloud setup (5 minutes): +1. [console.cloud.google.com](https://console.cloud.google.com) → a project → enable the **Gmail API**, **Google Calendar API** (and **Drive API** if you'll use it). +2. **OAuth consent screen**: user type **Internal** if you're on Google Workspace (no verification, no token expiry); personal Gmail must use External + add yourself as a test user (note: refresh tokens for External apps in *Testing* status expire after 7 days — publish to production to avoid re-connecting weekly). +3. **Credentials ▸ Create credentials ▸ OAuth client ID ▸ Web application**, authorized redirect URIs: `http://localhost:7870/plugins/google/oauth/callback` (add `:7871` for the dev instance; the URI must exactly match the origin you open the console on). +4. Paste the client ID + secret into the plugin settings. + +Default scopes requested: `gmail.modify`, `calendar`, `drive.readonly` (override via the `oauth_scopes` setting — request the set you intend to grow into so adding a service needs no re-consent). + +Manual fallback (headless / no browser): mint a refresh token yourself and set `google.refresh_token` (or `GOOGLE_REFRESH_TOKEN`); env fallbacks `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` work too. ## Install ```bash diff --git a/__init__.py b/__init__.py index 63c80cc..d758de1 100644 --- a/__init__.py +++ b/__init__.py @@ -164,6 +164,15 @@ def calendar_event_detail(event_id: str, calendar_id: str = "primary") -> str: ] +def _set_refresh_token(token: str) -> None: + """Swap the live refresh token in place (the OAuth callback's connect-now hook).""" + global _CREDS + from .auth import Creds + + c = _creds() + _CREDS = Creds(c.client_id, c.client_secret, token) + + def register(registry) -> None: """Entry point — called once per graph build with the live config.""" global _CREDS @@ -178,12 +187,17 @@ def register(registry) -> None: for t in TOOLS: registry.register_tool(t) - # Console view: public page (/plugins/google/view) + gated data (/api/plugins/google/*). + # Console view + one-click OAuth connect: public page (/plugins/google/view, + # /plugins/google/oauth/callback) + gated data (/api/plugins/google/*). try: from . import gcal, gmail from .view import build_router - page, data = build_router(_creds, gmail, gcal) + page, data = build_router( + _creds, gmail, gcal, + scopes_fn=lambda: (registry.live_config() or {}).get("oauth_scopes", ""), + on_refresh_token=_set_refresh_token, + ) registry.register_router(page) # default prefix /plugins/google (public via public_paths) registry.register_router(data, prefix="/api/plugins/google") except Exception: # noqa: BLE001 — view is best-effort diff --git a/gmail.py b/gmail.py index 42516b2..fceefb1 100644 --- a/gmail.py +++ b/gmail.py @@ -56,6 +56,11 @@ def _body(payload: dict, limit: int = 8192) -> str: return html[:limit] +def profile(creds: Creds, *, client=None) -> dict: + """The connected account's profile — {"emailAddress": ...}. Read-only.""" + return request(creds, "GET", f"{BASE}/profile", client=client) + + def list_messages(creds: Creds, q: str, max_results: int, *, client=None) -> list[dict]: listing = request(creds, "GET", f"{BASE}/messages", params={"q": q, "maxResults": min(int(max_results), 100)}, client=client) diff --git a/oauth.py b/oauth.py new file mode 100644 index 0000000..257a0f0 --- /dev/null +++ b/oauth.py @@ -0,0 +1,117 @@ +"""One-click OAuth connect — the authorization-code flow, for the operator. + +These routes serve the HUMAN, not the agent: the console view's "Connect Google" +button opens Google's consent screen; Google redirects back to the plugin's public +callback, which exchanges the code for a refresh token and persists it into the +host's untracked ``secrets.yaml``. The operator supplies only a client_id + +client_secret (their own Google Cloud OAuth client) — no manual token minting. + +START must be called through the gated ``/api/plugins/google`` router (operator +bearer); CALLBACK is public (Google's redirect can't carry a bearer) and is bound +to a single-use, short-lived ``state`` nonce minted by START. +""" + +from __future__ import annotations + +import secrets as _secrets +import time +import urllib.parse + +import httpx + +from .auth import OAUTH_TOKEN_URL + +AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth" + +# The "grow into" scope set (see README): Gmail read+draft, Calendar, Drive read. +# Override with the `oauth_scopes` config key (space-separated) before connecting. +DEFAULT_SCOPES = ( + "https://www.googleapis.com/auth/gmail.modify", + "https://www.googleapis.com/auth/calendar", + "https://www.googleapis.com/auth/drive.readonly", +) + +_STATE_TTL_S = 600 +_PENDING: dict[str, float] = {} # state nonce -> expiry (single-use) + + +class OAuthFlowError(RuntimeError): + """A connect-flow failure surfaced to the operator as readable text.""" + + +def begin(client_id: str, redirect_uri: str, scopes: str = "") -> str: + """Mint a state nonce and build the Google consent URL to open.""" + now = time.time() + for s, exp in list(_PENDING.items()): # drop stale nonces + if exp < now: + _PENDING.pop(s, None) + state = _secrets.token_urlsafe(24) + _PENDING[state] = now + _STATE_TTL_S + params = { + "client_id": client_id, + "redirect_uri": redirect_uri, + "response_type": "code", + "scope": scopes or " ".join(DEFAULT_SCOPES), + # offline + consent forces Google to issue a refresh token every time, + # not just on the account's first grant. + "access_type": "offline", + "prompt": "consent", + "state": state, + } + return f"{AUTH_URL}?{urllib.parse.urlencode(params)}" + + +def claim_state(state: str) -> bool: + """Validate and consume a callback's state nonce (single-use, TTL-bound).""" + exp = _PENDING.pop(state or "", None) + return exp is not None and exp >= time.time() + + +def exchange(client_id: str, client_secret: str, code: str, redirect_uri: str, + *, client: httpx.Client | None = None) -> dict: + """Swap the authorization code for tokens. Returns Google's token payload. + + Raises OAuthFlowError with Google's error description on failure, or when the + grant came back without a refresh token (e.g. a client/consent misconfig). + """ + owns = client is None + c = client or httpx.Client(timeout=30) + try: + resp = c.post(OAUTH_TOKEN_URL, data={ + "grant_type": "authorization_code", + "client_id": client_id, + "client_secret": client_secret, + "code": code, + "redirect_uri": redirect_uri, + }) + payload = resp.json() if resp.content else {} + if resp.status_code != 200: + detail = payload.get("error_description") or payload.get("error") or f"HTTP {resp.status_code}" + raise OAuthFlowError(f"token exchange failed: {detail}") + finally: + if owns: + c.close() + if not payload.get("refresh_token"): + raise OAuthFlowError( + "Google returned no refresh token. Re-try the connect (the flow requests " + "prompt=consent, which should force one); if it persists, revoke the app under " + "myaccount.google.com/permissions and connect again." + ) + return payload + + +def persist_refresh_token(section: str, token: str) -> bool: + """Merge the refresh token into the host's untracked secrets.yaml (0600). + + ``refresh_token`` is a declared plugin secret (manifest ``secrets:``), so the + host redacts it from config reads and purges it on uninstall. Host-only import + stays lazy; returns False when there's no host (unit tests) so the caller can + say so instead of crashing the callback. + """ + try: + from graph.config_io import save_secrets # host-only, lazy + + save_secrets({section: {"refresh_token": token}}) + return True + except Exception: # noqa: BLE001 — no host / write failure ⇒ report, don't crash + return False diff --git a/protoagent.plugin.yaml b/protoagent.plugin.yaml index 6d7bed5..09fcda0 100644 --- a/protoagent.plugin.yaml +++ b/protoagent.plugin.yaml @@ -9,7 +9,9 @@ description: >- Docs, and Sheets are additive modules, not a rewrite. Pull-mode: the agent reads and drafts; a human reviews and sends. Needs a Google OAuth client + refresh token; mint it with the full scope set you intend to grow into (e.g. gmail.modify, - calendar, drive, documents, spreadsheets). + calendar, drive, documents, spreadsheets). One-click connect: paste the OAuth + client id + secret, then hit Connect Google in the console view — the plugin runs + the consent flow and stores the refresh token itself. enabled: false min_protoagent_version: "0.71.0" # manifest public_paths (#1345) capabilities: @@ -23,14 +25,17 @@ views: - { id: google, label: Google, icon: Mail, path: /plugins/google/view, placement: right } public_paths: - /plugins/google/view + - /plugins/google/oauth/callback # Google's redirect can't carry a bearer; gated by a single-use state nonce config_section: google config: client_id: "" client_secret: "" refresh_token: "" + oauth_scopes: "" secrets: [client_secret, refresh_token] settings: - { key: client_id, label: "OAuth client ID", type: string, description: "Google Cloud OAuth 2.0 client ID. Falls back to GOOGLE_CLIENT_ID." } - { key: client_secret, label: "OAuth client secret", type: secret, description: "Stored in secrets.yaml. Falls back to GOOGLE_CLIENT_SECRET." } - - { key: refresh_token, label: "OAuth refresh token", type: secret, description: "Long-lived refresh token (gmail.modify + calendar scopes). Falls back to GOOGLE_REFRESH_TOKEN." } + - { key: refresh_token, label: "OAuth refresh token", type: secret, description: "Filled automatically by Connect Google in the console view (or paste one minted manually). Falls back to GOOGLE_REFRESH_TOKEN." } + - { key: oauth_scopes, label: "OAuth scopes", type: string, description: "Space-separated scopes Connect Google requests. Blank = gmail.modify + calendar + drive.readonly." } diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 0000000..afe2300 --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,164 @@ +"""One-click OAuth connect flow — state nonce, exchange, callback wiring. No network.""" + +from __future__ import annotations + +import urllib.parse +from pathlib import Path + +import google_plugin as plugin +import httpx +import pytest +import yaml +from fastapi import FastAPI +from fastapi.testclient import TestClient +from google_plugin import gcal, gmail, oauth, view +from google_plugin.auth import Creds + +ROOT = Path(__file__).resolve().parent.parent +MANIFEST = yaml.safe_load((ROOT / "protoagent.plugin.yaml").read_text()) + +CLIENT = Creds("cid", "csecret", "") + + +@pytest.fixture(autouse=True) +def _clean_state(): + oauth._PENDING.clear() + yield + oauth._PENDING.clear() + + +def test_begin_builds_consent_url_and_registers_state(): + url = oauth.begin("cid", "http://localhost:7870/plugins/google/oauth/callback") + parsed = urllib.parse.urlparse(url) + q = dict(urllib.parse.parse_qsl(parsed.query)) + assert url.startswith(oauth.AUTH_URL) + assert q["client_id"] == "cid" and q["redirect_uri"].endswith("/oauth/callback") + assert q["access_type"] == "offline" and q["prompt"] == "consent" # forces a refresh token + assert q["scope"] == " ".join(oauth.DEFAULT_SCOPES) + assert q["state"] in oauth._PENDING + + +def test_begin_scope_override(): + url = oauth.begin("cid", "http://x/cb", scopes="a b") + q = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query)) + assert q["scope"] == "a b" + + +def test_claim_state_is_single_use_and_ttl_bound(monkeypatch): + oauth.begin("cid", "http://x/cb") + (state,) = oauth._PENDING + assert oauth.claim_state(state) is True + assert oauth.claim_state(state) is False # single-use + assert oauth.claim_state("unknown") is False + oauth._PENDING["stale"] = 1.0 # long expired + assert oauth.claim_state("stale") is False + + +def test_exchange_returns_payload_and_requires_refresh_token(): + def handler(request: httpx.Request) -> httpx.Response: + body = dict(urllib.parse.parse_qsl(request.content.decode())) + assert body["grant_type"] == "authorization_code" and body["code"] == "the-code" + return httpx.Response(200, json={"access_token": "at", "refresh_token": "rt-1"}) + + with httpx.Client(transport=httpx.MockTransport(handler)) as c: + payload = oauth.exchange("cid", "cs", "the-code", "http://x/cb", client=c) + assert payload["refresh_token"] == "rt-1" + + with httpx.Client(transport=httpx.MockTransport(lambda r: httpx.Response(200, json={"access_token": "at"}))) as c: + with pytest.raises(oauth.OAuthFlowError, match="no refresh token"): + oauth.exchange("cid", "cs", "the-code", "http://x/cb", client=c) + + +def test_exchange_surfaces_google_error_description(): + resp = httpx.Response(400, json={"error": "invalid_grant", "error_description": "Bad code"}) + with httpx.Client(transport=httpx.MockTransport(lambda r: resp)) as c: + with pytest.raises(oauth.OAuthFlowError, match="Bad code"): + oauth.exchange("cid", "cs", "x", "http://x/cb", client=c) + + +def test_persist_refresh_token_reports_no_host(): + assert oauth.persist_refresh_token("google", "rt") is False # no graph.config_io here + + +# ── Callback route, mounted as the host mounts it ───────────────────────────── + + +def _app(on_refresh_token=None, scopes_fn=None) -> FastAPI: + page, data = view.build_router(lambda: CLIENT, gmail, gcal, scopes_fn=scopes_fn, on_refresh_token=on_refresh_token) + app = FastAPI() + app.include_router(page, prefix="/plugins/google") + app.include_router(data, prefix="/api/plugins/google") + return app + + +def test_callback_path_is_public_in_manifest(): + assert "/plugins/google/oauth/callback" in MANIFEST["public_paths"] + + +def test_oauth_start_requires_client_creds(): + page, data = view.build_router(lambda: Creds("", "", ""), gmail, gcal) + app = FastAPI() + app.include_router(data, prefix="/api/plugins/google") + out = TestClient(app).post("/api/plugins/google/oauth/start").json() + assert "client_id" in out["error"] + + +def test_oauth_start_mints_url_with_request_derived_redirect(): + c = TestClient(_app(scopes_fn=lambda: "s1 s2")) + out = c.post("/api/plugins/google/oauth/start").json() + q = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(out["url"]).query)) + assert q["redirect_uri"] == "http://testserver/plugins/google/oauth/callback" + assert q["scope"] == "s1 s2" + assert q["state"] in oauth._PENDING + + +def test_callback_rejects_unknown_state(): + r = TestClient(_app()).get("/plugins/google/oauth/callback", params={"code": "x", "state": "bogus"}) + assert r.status_code == 400 and "stale" in r.text + + +def test_callback_reports_user_denial(): + r = TestClient(_app()).get("/plugins/google/oauth/callback", params={"error": "access_denied"}) + assert r.status_code == 400 and "access_denied" in r.text + + +def test_callback_exchanges_persists_and_swaps_creds(monkeypatch): + got: dict = {} + monkeypatch.setattr( + oauth, "exchange", lambda cid, cs, code, uri, **kw: got.update(code=code, uri=uri) or {"refresh_token": "rt-9"} + ) + monkeypatch.setattr( + oauth, "persist_refresh_token", lambda section, tok: got.update(persisted=(section, tok)) or True + ) + c = TestClient(_app(on_refresh_token=lambda t: got.update(live=t))) + c.post("/api/plugins/google/oauth/start") + (state,) = oauth._PENDING + r = c.get("/plugins/google/oauth/callback", params={"code": "the-code", "state": state}) + assert r.status_code == 200 and "connected" in r.text.lower() + assert got["code"] == "the-code" and got["uri"] == "http://testserver/plugins/google/oauth/callback" + assert got["persisted"] == ("google", "rt-9") and got["live"] == "rt-9" + + +def test_callback_warns_when_persist_fails(monkeypatch): + monkeypatch.setattr(oauth, "exchange", lambda *a, **kw: {"refresh_token": "rt-9"}) + monkeypatch.setattr(oauth, "persist_refresh_token", lambda *a: False) + c = TestClient(_app(on_refresh_token=lambda t: None)) + c.post("/api/plugins/google/oauth/start") + (state,) = oauth._PENDING + r = c.get("/plugins/google/oauth/callback", params={"code": "x", "state": state}) + assert r.status_code == 200 and "secrets.yaml" in r.text + + +def test_set_refresh_token_swaps_live_creds(monkeypatch): + monkeypatch.setattr(plugin, "_CREDS", Creds("cid", "cs", "old")) + plugin._set_refresh_token("new") + assert plugin._creds().refresh_token == "new" and plugin._creds().client_id == "cid" + + +def test_status_reports_has_client_and_email(monkeypatch): + monkeypatch.setattr(gmail, "profile", lambda c, **kw: {"emailAddress": "josh@x.com"}) + page, data = view.build_router(lambda: Creds("cid", "cs", "rt"), gmail, gcal) + app = FastAPI() + app.include_router(data, prefix="/api/plugins/google") + out = TestClient(app).get("/api/plugins/google/status").json() + assert out == {"configured": True, "has_client": True, "email": "josh@x.com"} diff --git a/tests/test_plugin.py b/tests/test_plugin.py index b671a8a..f6919ff 100644 --- a/tests/test_plugin.py +++ b/tests/test_plugin.py @@ -117,7 +117,7 @@ def test_manifest_view_path_is_served_and_public(): def test_data_routes_are_gated_prefix_and_degrade_unconfigured(monkeypatch): monkeypatch.setattr(plugin, "_CREDS", Creds("", "", "")) c = TestClient(_mounted_app()) - assert c.get("/api/plugins/google/status").json() == {"configured": False} + assert c.get("/api/plugins/google/status").json() == {"configured": False, "has_client": False} assert c.get("/api/plugins/google/unread").json() == {"messages": []} assert c.get("/api/plugins/google/upcoming").json() == {"events": []} diff --git a/view.py b/view.py index 20d44de..62debd8 100644 --- a/view.py +++ b/view.py @@ -3,8 +3,14 @@ Public page at /plugins/google/view (declared in manifest public_paths); links the host design-system plugin-kit so it's themed from the operator's live `--pl-*` tokens, and uses the kit's `apiFetch` to read the gated /api/plugins/google/* routes. + +Also owns the one-click OAuth connect routes (see oauth.py): the gated START +(operator-only, mints the consent URL) and the public CALLBACK (Google's redirect +lands here; validated by the single-use state nonce START minted). """ +import html + PAGE = r"""
{detail}