Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
5 changes: 5 additions & 0 deletions gmail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
117 changes: 117 additions & 0 deletions oauth.py
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions protoagent.plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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." }
164 changes: 164 additions & 0 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
@@ -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"}
2 changes: 1 addition & 1 deletion tests/test_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}

Expand Down
Loading
Loading