533 lines
20 KiB
Python
533 lines
20 KiB
Python
"""User OAuth helper for the Google Chat gateway adapter.
|
|
|
|
Google Chat's ``media.upload`` hard-rejects service-account auth ("This method
|
|
doesn't support app authentication with a service account"), so for native
|
|
file attachments each user grants the bot ``chat.messages.create`` ONCE in
|
|
their own DM. The bot stores per-user refresh tokens and uploads *as the user*.
|
|
See https://developers.google.com/chat/api/guides/auth/users.
|
|
|
|
Both a library (imported by the adapter) and a CLI (driven by ``/setup-files``):
|
|
|
|
Library: load_user_credentials(email=None), refresh_or_none(creds, email=None),
|
|
build_user_chat_service(creds), list_authorized_emails()
|
|
CLI: --check | --client-secret PATH | --auth-url | --auth-code CODE |
|
|
--revoke | --install-deps [--email EMAIL] (legacy single-user
|
|
mode when --email is omitted)
|
|
|
|
Token storage layout
|
|
--------------------
|
|
- Per-user tokens: ``${HERMES_HOME}/google_chat_user_tokens/<sanitized_email>.json``
|
|
- Legacy single-user: ``${HERMES_HOME}/google_chat_user_token.json``
|
|
- Per-user pending PKCE state: ``${HERMES_HOME}/google_chat_user_oauth_pending/<sanitized_email>.json``
|
|
- Legacy pending state: ``${HERMES_HOME}/google_chat_user_oauth_pending.json``
|
|
- OAuth client secret (profile-scoped): ``${HERMES_HOME}/google_chat_user_client_secret.json``
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
import secrets
|
|
import stat
|
|
import sys
|
|
from importlib.metadata import version as _distribution_version
|
|
from pathlib import Path
|
|
from typing import Any, List, NoReturn, Optional, Tuple
|
|
|
|
from packaging.requirements import Requirement
|
|
|
|
# Pinned legacy logger name so operator log filters keep matching (see adapter.py).
|
|
logger = logging.getLogger("gateway.platforms.google_chat_user_oauth")
|
|
|
|
try:
|
|
from hermes_constants import display_hermes_home, get_hermes_home
|
|
except (ModuleNotFoundError, ImportError):
|
|
# Mirrors the google-workspace skill's _hermes_home.py shim.
|
|
def get_hermes_home() -> Path:
|
|
val = os.environ.get("HERMES_HOME", "").strip()
|
|
return Path(val) if val else Path.home() / ".hermes"
|
|
|
|
def display_hermes_home() -> str:
|
|
home = get_hermes_home()
|
|
try:
|
|
return "~/" + home.relative_to(Path.home()).as_posix()
|
|
except ValueError:
|
|
return str(home)
|
|
|
|
from utils import atomic_replace
|
|
|
|
|
|
def _hermes_home() -> Path:
|
|
"""Resolve HERMES_HOME at call time (late-binding for tests / profile switches)."""
|
|
return get_hermes_home()
|
|
|
|
|
|
# Filesystem-safe key: lowercase, keep ``[a-z0-9._-@]`` so token files stay
|
|
# human-readable under ``ls ~/.hermes/google_chat_user_tokens/``.
|
|
_EMAIL_FS_RE = re.compile(r"[^a-z0-9._@-]+")
|
|
|
|
|
|
def _sanitize_email(email: str) -> str:
|
|
cleaned = _EMAIL_FS_RE.sub("_", (email or "").strip().lower())
|
|
return cleaned or "_unknown_"
|
|
|
|
|
|
def _user_tokens_dir() -> Path:
|
|
return _hermes_home() / "google_chat_user_tokens"
|
|
|
|
|
|
def _token_path(email: Optional[str] = None) -> Path:
|
|
"""Per-user token path for ``email``, or the legacy single-user path."""
|
|
if email:
|
|
return _user_tokens_dir() / f"{_sanitize_email(email)}.json"
|
|
return _hermes_home() / "google_chat_user_token.json"
|
|
|
|
|
|
def _client_secret_path() -> Path:
|
|
return _hermes_home() / "google_chat_user_client_secret.json"
|
|
|
|
|
|
def _pending_auth_path(email: Optional[str] = None) -> Path:
|
|
if email:
|
|
return _hermes_home() / "google_chat_user_oauth_pending" / f"{_sanitize_email(email)}.json"
|
|
return _hermes_home() / "google_chat_user_oauth_pending.json"
|
|
|
|
|
|
# Least privilege: chat.messages.create covers BOTH media.upload and the
|
|
# subsequent messages.create; no drive.file or other scopes.
|
|
SCOPES: List[str] = [
|
|
"https://www.googleapis.com/auth/chat.messages.create",
|
|
]
|
|
|
|
# Pip packages required by the Google Chat adapter and its OAuth flow.
|
|
_REQUIRED_PACKAGES = [
|
|
"google-cloud-pubsub==2.39.0",
|
|
"google-api-python-client==2.194.0",
|
|
"google-auth==2.55.1",
|
|
"google-auth-oauthlib==1.3.1",
|
|
"google-auth-httplib2==0.3.1",
|
|
"httplib2==0.32.0",
|
|
"pyasn1==0.6.4",
|
|
]
|
|
|
|
# Google deprecated the ``oob`` flow: use a localhost redirect that is expected
|
|
# to FAIL; the user pastes the code from the failed browser URL back into chat.
|
|
_REDIRECT_URI = "http://localhost:1"
|
|
|
|
|
|
# =============================================================================
|
|
# Library API — called from the adapter at runtime
|
|
# =============================================================================
|
|
|
|
|
|
def _refresh_and_persist(creds: Any, token_path: Path, request_cls: Any) -> Optional[Any]:
|
|
"""Refresh expired creds and write them back; None when unusable or refresh fails."""
|
|
if creds.valid:
|
|
return creds
|
|
if creds.expired and creds.refresh_token:
|
|
try:
|
|
creds.refresh(request_cls())
|
|
except Exception as exc:
|
|
logger.warning("[google_chat_user_oauth] token refresh failed (user should re-run /setup-files): %s", exc)
|
|
return None
|
|
_persist_credentials(creds, token_path)
|
|
return creds
|
|
# Token exists but is unusable (e.g. revoked, no refresh token).
|
|
return None
|
|
|
|
|
|
def load_user_credentials(email: Optional[str] = None) -> Optional[Any]:
|
|
"""Load + validate persisted user OAuth credentials.
|
|
|
|
``None`` email → legacy single-user path. Returns ``None`` (never raises) when
|
|
no token is stored, the token is corrupt, or refresh fails — callers treat
|
|
that as "user has not run /setup-files yet".
|
|
"""
|
|
token_path = _token_path(email)
|
|
if not token_path.exists():
|
|
return None
|
|
|
|
# Hand-provisioned / legacy token files commonly end up 0o644; warn the owner.
|
|
from utils import warn_if_credential_file_broadly_readable
|
|
|
|
warn_if_credential_file_broadly_readable(token_path, label="[google_chat_user_oauth]", log=logger)
|
|
|
|
try:
|
|
from google.oauth2.credentials import Credentials
|
|
from google.auth.transport.requests import Request
|
|
except ImportError:
|
|
logger.warning(
|
|
"[google_chat_user_oauth] google-auth not installed; user-OAuth "
|
|
"attachment delivery is disabled. Run `hermes setup` to install Google Chat support."
|
|
)
|
|
return None
|
|
|
|
try:
|
|
# No scopes: the user may have authorized a subset, and passing scopes
|
|
# makes refresh validate them strictly.
|
|
creds = Credentials.from_authorized_user_file(str(token_path))
|
|
except Exception as exc:
|
|
logger.warning("[google_chat_user_oauth] token at %s is corrupt: %s", token_path, exc)
|
|
return None
|
|
return _refresh_and_persist(creds, token_path, Request)
|
|
|
|
|
|
def refresh_or_none(creds: Any, email: Optional[str] = None) -> Optional[Any]:
|
|
"""Refresh ``creds`` if expired; ``None`` on failure (caller falls back to the
|
|
text-notice path). ``email`` selects where the refreshed token is written."""
|
|
if creds is None:
|
|
return None
|
|
if creds.valid:
|
|
return creds
|
|
try:
|
|
from google.auth.transport.requests import Request
|
|
except ImportError:
|
|
return None
|
|
if creds.expired and creds.refresh_token:
|
|
try:
|
|
creds.refresh(Request())
|
|
_persist_credentials(creds, _token_path(email))
|
|
return creds
|
|
except Exception as exc:
|
|
logger.warning("[google_chat_user_oauth] refresh failed: %s", exc)
|
|
return None
|
|
return None
|
|
|
|
|
|
def build_user_chat_service(creds: Any) -> Any:
|
|
"""Chat API client authenticated as the user (for media.upload + messages.create)."""
|
|
from googleapiclient.discovery import build as build_service
|
|
return build_service("chat", "v1", credentials=creds, cache_discovery=False)
|
|
|
|
|
|
def list_authorized_emails() -> List[str]:
|
|
"""Sanitized emails with stored per-user tokens (admin display only, not trust;
|
|
excludes the legacy single-user token whose owner is unknown)."""
|
|
d = _user_tokens_dir()
|
|
if not d.exists():
|
|
return []
|
|
return sorted(f.stem for f in d.iterdir() if f.is_file() and f.suffix == ".json")
|
|
|
|
|
|
def _persist_credentials(creds: Any, token_path: Path) -> None:
|
|
"""Persist refreshed credentials atomically with private permissions."""
|
|
try:
|
|
_write_private_json(token_path, _normalize_authorized_user_payload(json.loads(creds.to_json())))
|
|
except Exception:
|
|
logger.debug("[google_chat_user_oauth] failed to persist credentials at %s", token_path, exc_info=True)
|
|
|
|
|
|
# =============================================================================
|
|
# CLI commands — driven by the agent via /setup-files
|
|
# =============================================================================
|
|
|
|
|
|
def _normalize_authorized_user_payload(payload: dict) -> dict:
|
|
"""Ensure the persisted token JSON has the type field google-auth expects."""
|
|
normalized = dict(payload)
|
|
if not normalized.get("type"):
|
|
normalized["type"] = "authorized_user"
|
|
return normalized
|
|
|
|
|
|
def _write_private_json(path: Path, data: Any) -> None:
|
|
"""Atomically write JSON with 0o600 permissions where supported."""
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
os.chmod(path.parent, 0o700)
|
|
except OSError:
|
|
pass
|
|
|
|
tmp_path = path.with_suffix(f".tmp.{os.getpid()}.{secrets.token_hex(4)}")
|
|
try:
|
|
fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_EXCL, stat.S_IRUSR | stat.S_IWUSR)
|
|
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, indent=2, ensure_ascii=False)
|
|
fh.flush()
|
|
os.fsync(fh.fileno())
|
|
atomic_replace(tmp_path, path)
|
|
try:
|
|
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
try:
|
|
if tmp_path.exists():
|
|
tmp_path.unlink()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _fail(*lines: str) -> NoReturn:
|
|
"""Print CLI error lines and exit 1."""
|
|
for line in lines:
|
|
print(line)
|
|
sys.exit(1)
|
|
|
|
|
|
def _ensure_deps() -> None:
|
|
"""Check exact dependency versions; install if stale; exit on failure."""
|
|
if _missing_required_packages() and not install_deps():
|
|
sys.exit(1)
|
|
|
|
|
|
def _missing_required_packages() -> List[str]:
|
|
"""Return exact requirements absent or stale in this interpreter."""
|
|
missing = []
|
|
for spec in _REQUIRED_PACKAGES:
|
|
requirement = Requirement(spec)
|
|
try:
|
|
installed = _distribution_version(requirement.name)
|
|
satisfied = requirement.specifier.contains(installed, prereleases=True)
|
|
except Exception:
|
|
satisfied = False
|
|
if not satisfied:
|
|
missing.append(spec)
|
|
return missing
|
|
|
|
|
|
def install_deps() -> bool:
|
|
missing = _missing_required_packages()
|
|
if not missing:
|
|
print("Dependencies already installed.")
|
|
return True
|
|
|
|
print("Installing Google Chat dependencies...")
|
|
try:
|
|
from hermes_cli.tools_config import _pip_install
|
|
|
|
result = _pip_install(["--quiet"] + missing)
|
|
if result.returncode != 0:
|
|
raise RuntimeError((result.stderr or "install failed").strip()[:300])
|
|
remaining = _missing_required_packages()
|
|
if remaining:
|
|
raise RuntimeError("dependencies remain stale after install: " + " ".join(remaining))
|
|
print("Dependencies installed.")
|
|
return True
|
|
except Exception as exc:
|
|
print(f"ERROR: Failed to install dependencies: {exc}")
|
|
print("Run `hermes setup` to repair the managed installation, then retry.")
|
|
return False
|
|
|
|
|
|
def check_auth(email: Optional[str] = None) -> bool:
|
|
"""Print status; return True if creds are usable."""
|
|
token_path = _token_path(email)
|
|
if not token_path.exists():
|
|
print(f"NOT_AUTHENTICATED: No token at {token_path}")
|
|
return False
|
|
if load_user_credentials(email) is None:
|
|
print(f"TOKEN_INVALID: Re-run /setup-files (path: {token_path})")
|
|
return False
|
|
print(f"AUTHENTICATED: Token valid at {token_path}")
|
|
return True
|
|
|
|
|
|
def store_client_secret(path: str) -> None:
|
|
"""Validate and copy the user's OAuth client_secret.json into HERMES_HOME."""
|
|
src = Path(path).expanduser().resolve()
|
|
if not src.exists():
|
|
_fail(f"ERROR: File not found: {src}")
|
|
try:
|
|
data = json.loads(src.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError:
|
|
_fail("ERROR: File is not valid JSON.")
|
|
if "installed" not in data and "web" not in data:
|
|
_fail(
|
|
"ERROR: Not a Google OAuth client secret file (missing 'installed' or 'web' key).",
|
|
"Download from: https://console.cloud.google.com/apis/credentials",
|
|
)
|
|
target = _client_secret_path()
|
|
_write_private_json(target, data)
|
|
print(f"OK: Client secret saved to {target}")
|
|
|
|
|
|
def _save_pending_auth(*, state: str, code_verifier: str, email: Optional[str] = None) -> None:
|
|
_write_private_json(
|
|
_pending_auth_path(email),
|
|
{
|
|
"state": state,
|
|
"code_verifier": code_verifier,
|
|
"redirect_uri": _REDIRECT_URI,
|
|
"email": email or "",
|
|
},
|
|
)
|
|
|
|
|
|
def _load_pending_auth(email: Optional[str] = None) -> dict:
|
|
pending = _pending_auth_path(email)
|
|
if not pending.exists():
|
|
_fail("ERROR: No pending OAuth session found. Run --auth-url first.")
|
|
try:
|
|
data = json.loads(pending.read_text(encoding="utf-8"))
|
|
except Exception as exc:
|
|
_fail(f"ERROR: Could not read pending OAuth session: {exc}", "Run --auth-url again to start a fresh session.")
|
|
if not data.get("state") or not data.get("code_verifier"):
|
|
_fail("ERROR: Pending OAuth session is missing PKCE data.", "Run --auth-url again.")
|
|
return data
|
|
|
|
|
|
def _extract_code_and_state(code_or_url: str) -> Tuple[str, Optional[str]]:
|
|
"""Accept a raw auth code OR the full failed-redirect URL the user pastes."""
|
|
if not code_or_url.startswith("http"):
|
|
return code_or_url, None
|
|
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
params = parse_qs(urlparse(code_or_url).query)
|
|
if "code" not in params:
|
|
_fail("ERROR: No 'code' parameter found in URL.")
|
|
return params["code"][0], params.get("state", [None])[0]
|
|
|
|
|
|
def _require_client_secret() -> None:
|
|
if not _client_secret_path().exists():
|
|
_fail("ERROR: No client secret stored. Run --client-secret first.")
|
|
|
|
|
|
def get_auth_url(email: Optional[str] = None) -> None:
|
|
"""Print the OAuth URL for the user to visit; persists PKCE state under ``email``
|
|
so two users can be mid-flow in parallel."""
|
|
_require_client_secret()
|
|
_ensure_deps()
|
|
from google_auth_oauthlib.flow import Flow
|
|
|
|
flow = Flow.from_client_secrets_file(
|
|
str(_client_secret_path()),
|
|
scopes=SCOPES,
|
|
redirect_uri=_REDIRECT_URI,
|
|
autogenerate_code_verifier=True,
|
|
)
|
|
auth_url, state = flow.authorization_url(access_type="offline", prompt="consent")
|
|
_save_pending_auth(state=state, code_verifier=flow.code_verifier, email=email)
|
|
print(auth_url)
|
|
|
|
|
|
def exchange_auth_code(code: str, email: Optional[str] = None) -> None:
|
|
"""Exchange an auth code (or pasted redirect URL) for a refresh token stored
|
|
at the per-user path for ``email`` (legacy single-user path when None)."""
|
|
_require_client_secret()
|
|
pending_auth = _load_pending_auth(email)
|
|
raw_callback = code
|
|
code, returned_state = _extract_code_and_state(code)
|
|
if returned_state and returned_state != pending_auth["state"]:
|
|
_fail("ERROR: OAuth state mismatch. Run --auth-url again to start a fresh session.")
|
|
|
|
_ensure_deps()
|
|
from google_auth_oauthlib.flow import Flow
|
|
from urllib.parse import parse_qs, urlparse
|
|
|
|
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()),
|
|
scopes=granted_scopes,
|
|
redirect_uri=pending_auth.get("redirect_uri", _REDIRECT_URI),
|
|
state=pending_auth["state"],
|
|
code_verifier=pending_auth["code_verifier"],
|
|
)
|
|
try:
|
|
# Accept partial scopes — user may deselect items in the consent screen.
|
|
os.environ["OAUTHLIB_RELAX_TOKEN_SCOPE"] = "1"
|
|
flow.fetch_token(code=code)
|
|
except Exception as exc:
|
|
_fail(f"ERROR: Token exchange failed: {exc}", "The code may have expired. Run --auth-url to get a fresh URL.")
|
|
|
|
creds = flow.credentials
|
|
token_payload = _normalize_authorized_user_payload(json.loads(creds.to_json()))
|
|
actually_granted = list(creds.granted_scopes or []) if hasattr(creds, "granted_scopes") and creds.granted_scopes else []
|
|
if actually_granted:
|
|
token_payload["scopes"] = actually_granted
|
|
elif granted_scopes != SCOPES:
|
|
token_payload["scopes"] = granted_scopes
|
|
|
|
token_path = _token_path(email)
|
|
_write_private_json(token_path, token_payload)
|
|
_pending_auth_path(email).unlink(missing_ok=True)
|
|
|
|
print(f"OK: Authenticated. Token saved to {token_path}")
|
|
rel_label = (
|
|
f"{display_hermes_home()}/google_chat_user_tokens/{_sanitize_email(email)}.json"
|
|
if email
|
|
else f"{display_hermes_home()}/google_chat_user_token.json"
|
|
)
|
|
print(f"Profile path: {rel_label}")
|
|
|
|
|
|
def revoke(email: Optional[str] = None) -> None:
|
|
"""Revoke the stored token with Google and delete it locally."""
|
|
token_path = _token_path(email)
|
|
if not token_path.exists():
|
|
print("No token to revoke.")
|
|
return
|
|
|
|
_ensure_deps()
|
|
from google.oauth2.credentials import Credentials
|
|
from google.auth.transport.requests import Request
|
|
|
|
try:
|
|
creds = Credentials.from_authorized_user_file(str(token_path), SCOPES)
|
|
if creds.expired and creds.refresh_token:
|
|
creds.refresh(Request())
|
|
|
|
import urllib.request
|
|
urllib.request.urlopen(
|
|
urllib.request.Request(
|
|
f"https://oauth2.googleapis.com/revoke?token={creds.token}",
|
|
method="POST",
|
|
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
),
|
|
timeout=15,
|
|
)
|
|
print("Token revoked with Google.")
|
|
except Exception as exc:
|
|
print(f"Remote revocation failed (token may already be invalid): {exc}")
|
|
|
|
token_path.unlink(missing_ok=True)
|
|
_pending_auth_path(email).unlink(missing_ok=True)
|
|
print(f"Deleted {token_path}")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(
|
|
description="Google Chat user-OAuth setup for Hermes (native attachment delivery)"
|
|
)
|
|
group = parser.add_mutually_exclusive_group(required=True)
|
|
group.add_argument("--check", action="store_true", help="Check if auth is valid (exit 0=yes, 1=no)")
|
|
group.add_argument("--client-secret", metavar="PATH", help="Store OAuth client_secret.json")
|
|
group.add_argument("--auth-url", action="store_true", help="Print OAuth URL for user to visit")
|
|
group.add_argument("--auth-code", metavar="CODE", help="Exchange auth code for token")
|
|
group.add_argument("--revoke", action="store_true", help="Revoke and delete stored token")
|
|
group.add_argument("--install-deps", action="store_true", help="Install Python dependencies")
|
|
parser.add_argument(
|
|
"--email", metavar="EMAIL", default=None,
|
|
help="Scope operation to a specific user's token (default: legacy single-user path)",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
email = args.email or None
|
|
if args.check:
|
|
sys.exit(0 if check_auth(email) else 1)
|
|
elif args.client_secret:
|
|
store_client_secret(args.client_secret)
|
|
elif args.auth_url:
|
|
get_auth_url(email)
|
|
elif args.auth_code:
|
|
exchange_auth_code(args.auth_code, email)
|
|
elif args.revoke:
|
|
revoke(email)
|
|
elif args.install_deps:
|
|
sys.exit(0 if install_deps() else 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|