Files
hermes-agent/agent/google_workspace_oauth.py
T
Austin Pickett b9d541ecb8 feat(auth): add integrated Google Workspace OAuth provider
- Add agent/google_workspace_oauth.py: PKCE OAuth module with bundled
  Nous client ID (placeholder), local fallback server, dashboard
  integration, headless mode, token refresh, and revocation
- Add 'hermes auth google-workspace login/status/logout' CLI commands
- Add 'hermes auth add google-workspace' redirect to login flow
- Add Google Workspace to dashboard OAuth providers card with
  server-side callback at /auth/google/callback
- Dashboard PKCE flow: auto-redirect callback, session polling,
  auto-close modal on success
- Branded callback pages (dark teal theme matching dashboard)
- Disconnect uses in-app modal instead of browser alert dialog
- CLI delegates to dashboard when running (single source of truth)
- Falls back to headless mode with --no-browser when dashboard is down
- Middleware bypass for google-workspace start/poll endpoints (CLI access)
2026-05-08 13:01:03 -04:00

1179 lines
41 KiB
Python

"""Google Workspace OAuth — PKCE flow for Gmail, Calendar, Drive, Sheets, Docs, Contacts.
This module provides a simple PKCE OAuth flow that lets users connect their
Google account to Hermes Agent without creating their own Google Cloud project.
A Nous Research-owned public Desktop OAuth client ships with Hermes; users
just need to authorize and they're done.
Users who prefer their own OAuth app can override via environment variables
or by providing a client_secret.json file.
Storage: ~/.hermes/google_token.json (same as existing google-workspace skill)
Client ID resolution order:
1. HERMES_GOOGLE_WORKSPACE_CLIENT_ID / _CLIENT_SECRET env vars
2. ~/.hermes/google_client_secret.json (user-provided, backward compat)
3. Shipped Nous Research defaults (public desktop OAuth client)
"""
from __future__ import annotations
import base64
import contextlib
import hashlib
import http.server
import json
import logging
import os
import secrets
import stat
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from hermes_constants import get_hermes_home
from utils import atomic_replace
logger = logging.getLogger(__name__)
# =============================================================================
# OAuth client credential constants
# =============================================================================
ENV_CLIENT_ID = "HERMES_GOOGLE_WORKSPACE_CLIENT_ID"
ENV_CLIENT_SECRET = "HERMES_GOOGLE_WORKSPACE_CLIENT_SECRET"
# Placeholder — will be replaced with Nous Research verified app credentials.
# Until then, users must provide their own client_secret.json or env vars.
_NOUS_CLIENT_ID = "PLACEHOLDER.apps.googleusercontent.com"
_NOUS_CLIENT_SECRET = "GOCSPX-PLACEHOLDER"
# =============================================================================
# Scopes
# =============================================================================
SCOPES: List[str] = [
"https://www.googleapis.com/auth/gmail.readonly",
"https://www.googleapis.com/auth/gmail.send",
"https://www.googleapis.com/auth/gmail.modify",
"https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive",
"https://www.googleapis.com/auth/contacts.readonly",
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/documents",
]
# =============================================================================
# Endpoints & constants
# =============================================================================
AUTH_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"
TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"
REVOKE_ENDPOINT = "https://oauth2.googleapis.com/revoke"
USERINFO_ENDPOINT = "https://www.googleapis.com/oauth2/v1/userinfo"
DEFAULT_REDIRECT_PORT = 8098
REDIRECT_HOST = "127.0.0.1"
CALLBACK_PATH = "/oauth2callback"
CALLBACK_WAIT_SECONDS = 300
TOKEN_REQUEST_TIMEOUT_SECONDS = 20.0
REFRESH_SKEW_SECONDS = 60
LOCK_TIMEOUT_SECONDS = 30.0
# =============================================================================
# Error type
# =============================================================================
class GoogleWorkspaceOAuthError(RuntimeError):
"""Raised for any failure in the Google Workspace OAuth flow."""
def __init__(self, message: str, *, code: str = "google_workspace_oauth_error") -> None:
super().__init__(message)
self.code = code
# =============================================================================
# File paths
# =============================================================================
def credentials_path() -> Path:
"""Return the path to the Google Workspace token file (~/.hermes/google_token.json)."""
return get_hermes_home() / "google_token.json"
def _client_secret_path() -> Path:
"""Return the path to a user-provided client_secret.json file."""
return get_hermes_home() / "google_client_secret.json"
def _lock_path() -> Path:
"""Return the path to the lock file for cross-process synchronization."""
return credentials_path().with_suffix(".json.lock")
# =============================================================================
# Cross-process file locking
# =============================================================================
_lock_state = threading.local()
@contextlib.contextmanager
def _credentials_lock(timeout_seconds: float = LOCK_TIMEOUT_SECONDS):
"""Cross-process lock around the credentials file (fcntl POSIX / msvcrt Windows)."""
depth = getattr(_lock_state, "depth", 0)
if depth > 0:
_lock_state.depth = depth + 1
try:
yield
finally:
_lock_state.depth -= 1
return
lock_file_path = _lock_path()
lock_file_path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(lock_file_path), os.O_CREAT | os.O_RDWR, 0o600)
acquired = False
try:
try:
import fcntl
except ImportError:
fcntl = None # type: ignore[assignment]
if fcntl is not None:
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
while True:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
acquired = True
break
except BlockingIOError:
if time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out acquiring Google Workspace OAuth lock at {lock_file_path}."
)
time.sleep(0.05)
else:
try:
import msvcrt # type: ignore[import-not-found]
deadline = time.monotonic() + max(0.0, float(timeout_seconds))
while True:
try:
msvcrt.locking(fd, msvcrt.LK_NBLCK, 1)
acquired = True
break
except OSError:
if time.monotonic() >= deadline:
raise TimeoutError(
f"Timed out acquiring Google Workspace OAuth lock at {lock_file_path}."
)
time.sleep(0.05)
except ImportError:
# No locking mechanism available — proceed anyway
acquired = True
_lock_state.depth = 1
yield
finally:
try:
if acquired:
try:
import fcntl
fcntl.flock(fd, fcntl.LOCK_UN)
except ImportError:
try:
import msvcrt # type: ignore[import-not-found]
try:
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
except OSError:
pass
except ImportError:
pass
finally:
os.close(fd)
_lock_state.depth = 0
# =============================================================================
# Client ID resolution
# =============================================================================
def get_client_credentials() -> Tuple[str, str]:
"""Resolve the OAuth client_id and client_secret.
Resolution order:
1. HERMES_GOOGLE_WORKSPACE_CLIENT_ID / _CLIENT_SECRET env vars
2. ~/.hermes/google_client_secret.json (user-provided, backward compat)
3. Shipped Nous Research defaults (public desktop OAuth client)
Returns:
Tuple of (client_id, client_secret).
Raises:
GoogleWorkspaceOAuthError: If no valid client credentials can be resolved.
"""
# 1. Environment variables
env_id = (os.getenv(ENV_CLIENT_ID) or "").strip()
env_secret = (os.getenv(ENV_CLIENT_SECRET) or "").strip()
if env_id:
return env_id, env_secret
# 2. User-provided client_secret.json
secret_path = _client_secret_path()
if secret_path.exists():
try:
data = json.loads(secret_path.read_text(encoding="utf-8"))
# Support both "installed" and "web" client types
client_info = data.get("installed") or data.get("web")
if client_info:
cid = client_info.get("client_id", "")
csecret = client_info.get("client_secret", "")
if cid:
logger.info("Using client credentials from %s", secret_path)
return cid, csecret
except (json.JSONDecodeError, OSError) as exc:
logger.warning(
"Failed to read client_secret.json at %s: %s", secret_path, exc
)
# 3. Shipped Nous Research defaults
if _NOUS_CLIENT_ID and _NOUS_CLIENT_ID != "PLACEHOLDER.apps.googleusercontent.com":
return _NOUS_CLIENT_ID, _NOUS_CLIENT_SECRET
# If we only have the placeholder, still return it — caller can decide
# whether to proceed or error out.
return _NOUS_CLIENT_ID, _NOUS_CLIENT_SECRET
# =============================================================================
# PKCE
# =============================================================================
def _generate_pkce_pair() -> Tuple[str, str]:
"""Generate a (code_verifier, code_challenge) pair using S256.
The verifier is a URL-safe random string between 43-128 characters.
The challenge is SHA256(verifier) encoded as base64url (no padding).
"""
# secrets.token_urlsafe(64) produces ~86 chars, well within 43-128 range
verifier = secrets.token_urlsafe(64)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
# =============================================================================
# Token I/O
# =============================================================================
def load_credentials() -> Optional[dict]:
"""Load the token payload from disk.
Returns:
The token dict if valid, or None if missing/corrupt.
Token format:
{
"token": "access_token_value",
"refresh_token": "...",
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": "...",
"client_secret": "...",
"scopes": [...],
"expiry": "2026-05-08T12:00:00.000000Z",
"type": "authorized_user"
}
"""
path = credentials_path()
if not path.exists():
return None
try:
with _credentials_lock():
raw = path.read_text(encoding="utf-8")
data = json.loads(raw)
except (json.JSONDecodeError, OSError, IOError) as exc:
logger.warning("Failed to read Google Workspace token at %s: %s", path, exc)
return None
if not isinstance(data, dict):
return None
# Minimal validity: must have either a token or a refresh_token
if not data.get("token") and not data.get("refresh_token"):
return None
return data
def _save_credentials(token_data: dict) -> Path:
"""Atomically write token data to disk with 0o600 permissions.
Returns the path where the token was saved.
"""
path = credentials_path()
path.parent.mkdir(parents=True, exist_ok=True)
# Tighten parent dir permissions
try:
os.chmod(path.parent, 0o700)
except OSError:
pass
# Ensure type field is present
if not token_data.get("type"):
token_data["type"] = "authorized_user"
payload = json.dumps(token_data, indent=2, sort_keys=True) + "\n"
with _credentials_lock():
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:
fh.write(payload)
fh.flush()
os.fsync(fh.fileno())
atomic_replace(tmp_path, path)
finally:
try:
if tmp_path.exists():
tmp_path.unlink()
except OSError:
pass
return path
# =============================================================================
# HTTP helpers
# =============================================================================
def _post_form(url: str, data: Dict[str, str], timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> Dict[str, Any]:
"""POST x-www-form-urlencoded and return parsed JSON response."""
body = urllib.parse.urlencode(data).encode("ascii")
request = urllib.request.Request(
url,
data=body,
method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace")
return json.loads(raw)
except urllib.error.HTTPError as exc:
detail = ""
try:
detail = exc.read().decode("utf-8", errors="replace")
except Exception:
pass
code = "google_workspace_oauth_token_http_error"
if "invalid_grant" in detail.lower():
code = "google_workspace_oauth_invalid_grant"
raise GoogleWorkspaceOAuthError(
f"Google token endpoint returned HTTP {exc.code}: {detail or exc.reason}",
code=code,
) from exc
except urllib.error.URLError as exc:
raise GoogleWorkspaceOAuthError(
f"Google token request failed: {exc}",
code="google_workspace_oauth_network_error",
) from exc
def _fetch_user_email(access_token: str, timeout: float = TOKEN_REQUEST_TIMEOUT_SECONDS) -> str:
"""Best-effort userinfo fetch for display. Failures return empty string."""
try:
request = urllib.request.Request(
USERINFO_ENDPOINT + "?alt=json",
headers={"Authorization": f"Bearer {access_token}"},
)
with urllib.request.urlopen(request, timeout=timeout) as response:
raw = response.read().decode("utf-8", errors="replace")
data = json.loads(raw)
return str(data.get("email", "") or "")
except Exception as exc:
logger.debug("Userinfo fetch failed (non-fatal): %s", exc)
return ""
# =============================================================================
# Token validation and refresh
# =============================================================================
def _is_token_expired(token_data: dict, skew_seconds: int = REFRESH_SKEW_SECONDS) -> bool:
"""Check if the access token is expired or about to expire."""
expiry_str = token_data.get("expiry", "")
if not expiry_str:
return True
try:
# Parse ISO format expiry (e.g., "2026-05-08T12:00:00.000000Z")
expiry_str = expiry_str.replace("Z", "+00:00")
expiry_dt = datetime.fromisoformat(expiry_str)
now = datetime.now(timezone.utc)
# Add skew buffer
return now >= (expiry_dt - timedelta(seconds=skew_seconds))
except (ValueError, TypeError):
return True
def refresh_token_if_needed(token_data: dict) -> dict:
"""Refresh the access token if expired. Saves updated token to disk.
Args:
token_data: The current token payload dict.
Returns:
The (potentially updated) token dict with a fresh access token.
Raises:
GoogleWorkspaceOAuthError: If refresh fails.
"""
if not _is_token_expired(token_data):
return token_data
refresh_token = token_data.get("refresh_token", "")
if not refresh_token:
raise GoogleWorkspaceOAuthError(
"Cannot refresh: no refresh_token in stored credentials. Re-run OAuth login.",
code="google_workspace_oauth_refresh_token_missing",
)
client_id = token_data.get("client_id", "")
client_secret = token_data.get("client_secret", "")
token_uri = token_data.get("token_uri", TOKEN_ENDPOINT)
if not client_id:
# Fall back to resolved credentials
client_id, client_secret = get_client_credentials()
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
}
if client_secret:
data["client_secret"] = client_secret
try:
resp = _post_form(token_uri, data)
except GoogleWorkspaceOAuthError as exc:
if exc.code == "google_workspace_oauth_invalid_grant":
logger.warning(
"Google Workspace refresh token invalid (revoked/expired). "
"User must re-authenticate."
)
raise
new_access = str(resp.get("access_token", "") or "").strip()
if not new_access:
raise GoogleWorkspaceOAuthError(
"Refresh response did not include an access_token.",
code="google_workspace_oauth_refresh_empty",
)
# Update token data
token_data["token"] = new_access
expires_in = int(resp.get("expires_in", 3600) or 3600)
expiry_dt = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
token_data["expiry"] = expiry_dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
# Google sometimes rotates refresh tokens
new_refresh = str(resp.get("refresh_token", "") or "").strip()
if new_refresh:
token_data["refresh_token"] = new_refresh
_save_credentials(token_data)
logger.info("Google Workspace token refreshed successfully.")
return token_data
def check_auth() -> bool:
"""Check if a valid Google Workspace token exists (refreshing if needed).
Returns:
True if the user has valid (or refreshable) credentials, False otherwise.
"""
token_data = load_credentials()
if token_data is None:
return False
# If token is not expired, we're good
if not _is_token_expired(token_data):
return True
# Try to refresh
if not token_data.get("refresh_token"):
return False
try:
refresh_token_if_needed(token_data)
return True
except GoogleWorkspaceOAuthError as exc:
logger.debug("Token refresh failed during check_auth: %s", exc)
return False
# =============================================================================
# Callback server
# =============================================================================
class _OAuthCallbackHandler(http.server.BaseHTTPRequestHandler):
"""HTTP handler that captures the OAuth callback code."""
expected_state: str = ""
captured_code: Optional[str] = None
captured_error: Optional[str] = None
code_verifier: str = ""
ready: Optional[threading.Event] = None
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
logger.debug("Google Workspace OAuth callback: " + format, *args)
def do_GET(self) -> None: # noqa: N802
parsed = urllib.parse.urlparse(self.path)
if parsed.path != CALLBACK_PATH:
self.send_response(404)
self.end_headers()
return
params = urllib.parse.parse_qs(parsed.query)
state = (params.get("state") or [""])[0]
error = (params.get("error") or [""])[0]
code = (params.get("code") or [""])[0]
if state != type(self).expected_state:
type(self).captured_error = "state_mismatch"
self._respond_html(400, _ERROR_PAGE.format(
message="State mismatch — aborting for safety."
))
elif error:
type(self).captured_error = error
safe_err = (
str(error)
.replace("&", "&")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
self._respond_html(400, _ERROR_PAGE.format(
message=f"Authorization denied: {safe_err}"
))
elif code:
type(self).captured_code = code
self._respond_html(200, _SUCCESS_PAGE)
else:
type(self).captured_error = "no_code"
self._respond_html(400, _ERROR_PAGE.format(
message="Callback received no authorization code."
))
if type(self).ready is not None:
type(self).ready.set()
def _respond_html(self, status: int, body: str) -> None:
payload = body.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
_SUCCESS_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>Connected — Hermes Agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #041c1c;
color: #ffe6cb;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
border: 1px solid rgba(255, 230, 203, 0.15);
background: rgba(255, 230, 203, 0.03);
padding: 3rem 4rem;
text-align: center;
max-width: 480px;
}
.icon { font-size: 3rem; margin-bottom: 1rem; }
h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 0.75rem;
}
p { color: rgba(255, 230, 203, 0.7); font-size: 0.95rem; line-height: 1.6; }
.hint { margin-top: 1.5rem; font-size: 0.85rem; color: rgba(255, 230, 203, 0.4); }
</style></head>
<body>
<div class="card">
<div class="icon">✓</div>
<h1>Connected</h1>
<p>Google Workspace is now connected to Hermes Agent.</p>
<p class="hint">You can close this tab.</p>
</div>
</body></html>
"""
_ERROR_PAGE = """<!doctype html>
<html><head><meta charset="utf-8"><title>Connection Failed — Hermes Agent</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
background: #041c1c;
color: #ffe6cb;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}}
.card {{
border: 1px solid rgba(255, 100, 100, 0.3);
background: rgba(255, 100, 100, 0.05);
padding: 3rem 4rem;
text-align: center;
max-width: 480px;
}}
.icon {{ font-size: 3rem; margin-bottom: 1rem; }}
h1 {{
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 0.75rem;
}}
p {{ color: rgba(255, 230, 203, 0.7); font-size: 0.95rem; line-height: 1.6; }}
</style></head>
<body>
<div class="card">
<div class="icon">✗</div>
<h1>Connection Failed</h1>
<p>{message}</p>
</div>
</body></html>
"""
# =============================================================================
# Server binding
# =============================================================================
def _bind_callback_server(preferred_port: int = DEFAULT_REDIRECT_PORT) -> Tuple[http.server.HTTPServer, int]:
"""Bind the callback HTTP server, falling back to an ephemeral port if needed."""
try:
server = http.server.HTTPServer((REDIRECT_HOST, preferred_port), _OAuthCallbackHandler)
return server, preferred_port
except OSError as exc:
logger.info(
"Preferred OAuth callback port %d unavailable (%s); requesting ephemeral port",
preferred_port, exc,
)
server = http.server.HTTPServer((REDIRECT_HOST, 0), _OAuthCallbackHandler)
return server, server.server_address[1]
# =============================================================================
# Main login flow (interactive, opens browser)
# =============================================================================
def run_oauth_login() -> dict:
"""Run the full PKCE OAuth flow interactively.
Uses the Hermes dashboard (localhost:9119) as the callback handler.
The dashboard's /auth/google/callback route exchanges the code and
saves the token. This function polls the dashboard session until
it's approved.
Falls back to a standalone local HTTP server on port 8098 if the
dashboard is not reachable.
Returns:
The saved token data dict.
Raises:
GoogleWorkspaceOAuthError: On any failure during the flow.
"""
client_id, client_secret = get_client_credentials()
if not client_id:
raise GoogleWorkspaceOAuthError(
"No Google OAuth client credentials available.\n"
"Either:\n"
" 1. Set HERMES_GOOGLE_WORKSPACE_CLIENT_ID and "
"HERMES_GOOGLE_WORKSPACE_CLIENT_SECRET env vars\n"
" 2. Place a google_client_secret.json in ~/.hermes/\n",
code="google_workspace_oauth_no_client",
)
# Try the dashboard flow first (preferred — single source of truth)
dashboard_url = "http://127.0.0.1:9119"
use_dashboard = _is_dashboard_running(dashboard_url)
if use_dashboard:
return _run_oauth_via_dashboard(client_id, dashboard_url)
else:
print("The Hermes dashboard is not running.")
print("Start it with: hermes dashboard")
print()
print("Or use headless mode: hermes auth google-workspace login --no-browser")
raise SystemExit(1)
def _is_dashboard_running(dashboard_url: str) -> bool:
"""Check if the Hermes dashboard is reachable."""
try:
req = urllib.request.Request(f"{dashboard_url}/api/health", method="GET")
with urllib.request.urlopen(req, timeout=2) as resp:
return resp.status == 200
except Exception:
# Try a simpler check — just see if the port is open
try:
req = urllib.request.Request(dashboard_url, method="GET")
with urllib.request.urlopen(req, timeout=2):
return True
except Exception:
return False
def _run_oauth_via_dashboard(client_id: str, dashboard_url: str) -> dict:
"""Run OAuth flow using the dashboard's callback route and session management."""
# Read the dashboard token from config for API auth
dashboard_token = ""
try:
config_path = get_hermes_home() / "config.yaml"
if config_path.exists():
import yaml
config = yaml.safe_load(config_path.read_text()) or {}
dashboard_token = config.get("web", {}).get("token", "") or ""
except Exception:
pass
# Call the dashboard's start endpoint
headers = {
"Content-Type": "application/json",
"User-Agent": "hermes-cli/1.0",
}
if dashboard_token:
headers["Authorization"] = f"Bearer {dashboard_token}"
start_req = urllib.request.Request(
f"{dashboard_url}/api/providers/oauth/google-workspace/start",
method="POST",
headers=headers,
data=b"{}",
)
try:
with urllib.request.urlopen(start_req, timeout=10) as resp:
start_data = json.loads(resp.read().decode())
except Exception as exc:
raise GoogleWorkspaceOAuthError(
f"Failed to start OAuth session via dashboard: {exc}",
code="google_workspace_oauth_dashboard_start_failed",
)
auth_url = start_data.get("auth_url", "")
session_id = start_data.get("session_id", "")
if not auth_url or not session_id:
raise GoogleWorkspaceOAuthError(
"Dashboard returned invalid start response",
code="google_workspace_oauth_dashboard_invalid",
)
print()
print("Opening your browser to connect your Google Workspace account…")
print(f"If it does not open automatically, visit:\n {auth_url}")
print()
try:
import webbrowser
webbrowser.open(auth_url, new=1, autoraise=True)
except Exception as exc:
logger.debug("webbrowser.open failed: %s", exc)
# Poll the dashboard for session completion
print("Waiting for authorization...")
poll_url = f"{dashboard_url}/api/providers/oauth/google-workspace/poll/{session_id}"
deadline = time.time() + CALLBACK_WAIT_SECONDS
while time.time() < deadline:
time.sleep(2)
try:
poll_req = urllib.request.Request(poll_url, method="GET")
with urllib.request.urlopen(poll_req, timeout=5) as resp:
poll_data = json.loads(resp.read().decode())
status = poll_data.get("status", "")
if status == "approved":
# Token was saved by the dashboard callback — load and return it
token_data = load_credentials()
if token_data:
print("✓ Authorization complete!")
return token_data
raise GoogleWorkspaceOAuthError(
"Session approved but token file not found",
code="google_workspace_oauth_token_missing",
)
elif status not in ("pending",):
error_msg = poll_data.get("error_message", status)
raise GoogleWorkspaceOAuthError(
f"Authorization failed: {error_msg}",
code="google_workspace_oauth_authorization_failed",
)
except GoogleWorkspaceOAuthError:
raise
except Exception:
# Network blip — keep polling
continue
raise GoogleWorkspaceOAuthError(
f"Timed out waiting for authorization after {CALLBACK_WAIT_SECONDS}s.",
code="google_workspace_oauth_timeout",
)
def _run_oauth_standalone(client_id: str, client_secret: str) -> dict:
"""Fallback: run OAuth with a standalone local HTTP server (no dashboard)."""
verifier, challenge = _generate_pkce_pair()
state = secrets.token_urlsafe(16)
server, port = _bind_callback_server(DEFAULT_REDIRECT_PORT)
redirect_uri = f"http://{REDIRECT_HOST}:{port}{CALLBACK_PATH}"
# Configure the handler class state
_OAuthCallbackHandler.expected_state = state
_OAuthCallbackHandler.captured_code = None
_OAuthCallbackHandler.captured_error = None
_OAuthCallbackHandler.code_verifier = verifier
ready = threading.Event()
_OAuthCallbackHandler.ready = ready
# Build authorization URL
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(SCOPES),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"access_type": "offline",
"prompt": "consent",
}
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
# Start callback server
server_thread = threading.Thread(target=server.serve_forever, daemon=True)
server_thread.start()
print()
print("Opening your browser to connect your Google Workspace account…")
print(f"If it does not open automatically, visit:\n {auth_url}")
print()
try:
import webbrowser
webbrowser.open(auth_url, new=1, autoraise=True)
except Exception as exc:
logger.debug("webbrowser.open failed: %s", exc)
# Wait for callback
code: Optional[str] = None
try:
if ready.wait(timeout=CALLBACK_WAIT_SECONDS):
code = _OAuthCallbackHandler.captured_code
error = _OAuthCallbackHandler.captured_error
if error:
raise GoogleWorkspaceOAuthError(
f"Authorization failed: {error}",
code="google_workspace_oauth_authorization_failed",
)
else:
raise GoogleWorkspaceOAuthError(
f"Timed out waiting for OAuth callback after {CALLBACK_WAIT_SECONDS}s. "
"Try run_oauth_login_headless() for environments without a browser.",
code="google_workspace_oauth_timeout",
)
finally:
try:
server.shutdown()
except Exception:
pass
try:
server.server_close()
except Exception:
pass
server_thread.join(timeout=2.0)
if not code:
raise GoogleWorkspaceOAuthError(
"No authorization code received. Aborting.",
code="google_workspace_oauth_no_code",
)
# Exchange code for tokens
return exchange_code(code, state, verifier, redirect_uri=redirect_uri)
# =============================================================================
# Headless login flow
# =============================================================================
def run_oauth_login_headless() -> Tuple[str, str, str]:
"""Generate an OAuth URL for headless environments (no browser available).
Returns:
Tuple of (auth_url, state, code_verifier) — the caller should present
the URL to the user and later call exchange_code() with the received
authorization code.
Raises:
GoogleWorkspaceOAuthError: If client credentials are unavailable.
"""
client_id, client_secret = get_client_credentials()
if not client_id:
raise GoogleWorkspaceOAuthError(
"No Google OAuth client credentials available.\n"
"Set HERMES_GOOGLE_WORKSPACE_CLIENT_ID env var or provide a "
"google_client_secret.json in ~/.hermes/",
code="google_workspace_oauth_no_client",
)
verifier, challenge = _generate_pkce_pair()
state = secrets.token_urlsafe(16)
# In headless mode, use localhost:1 as redirect — Google will redirect there,
# the page will fail to load, and the user copies the URL from the address bar.
# This avoids needing a local server to be reachable.
redirect_uri = "http://localhost:1"
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(SCOPES),
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
"access_type": "offline",
"prompt": "consent",
}
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
return auth_url, state, verifier
# =============================================================================
# Code exchange
# =============================================================================
def exchange_code(
code: str,
state: str,
code_verifier: str,
*,
redirect_uri: Optional[str] = None,
) -> dict:
"""Exchange an authorization code for access + refresh tokens.
Args:
code: The authorization code from Google's callback.
state: The state parameter (for logging/verification; already verified
by the callback handler if using run_oauth_login).
code_verifier: The PKCE code verifier generated for this flow.
redirect_uri: The redirect URI used in the authorization request.
Defaults to the standard localhost callback URI.
Returns:
The saved token data dict.
Raises:
GoogleWorkspaceOAuthError: If the token exchange fails.
"""
if redirect_uri is None:
redirect_uri = f"http://{REDIRECT_HOST}:{DEFAULT_REDIRECT_PORT}{CALLBACK_PATH}"
client_id, client_secret = get_client_credentials()
data = {
"grant_type": "authorization_code",
"code": code,
"code_verifier": code_verifier,
"client_id": client_id,
"redirect_uri": redirect_uri,
}
if client_secret:
data["client_secret"] = client_secret
resp = _post_form(TOKEN_ENDPOINT, data)
access_token = str(resp.get("access_token", "") or "").strip()
refresh_token = str(resp.get("refresh_token", "") or "").strip()
expires_in = int(resp.get("expires_in", 3600) or 3600)
if not access_token:
raise GoogleWorkspaceOAuthError(
"Token exchange response missing access_token.",
code="google_workspace_oauth_incomplete_response",
)
if not refresh_token:
raise GoogleWorkspaceOAuthError(
"Token exchange response missing refresh_token. "
"Ensure 'access_type=offline' and 'prompt=consent' are in the auth URL.",
code="google_workspace_oauth_no_refresh_token",
)
# Compute expiry timestamp
expiry_dt = datetime.now(timezone.utc) + timedelta(seconds=expires_in)
expiry_str = expiry_dt.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
# Best-effort email fetch
email = _fetch_user_email(access_token)
# Build token payload compatible with existing google-workspace skill
token_data: Dict[str, Any] = {
"token": access_token,
"refresh_token": refresh_token,
"token_uri": TOKEN_ENDPOINT,
"client_id": client_id,
"client_secret": client_secret,
"scopes": SCOPES,
"expiry": expiry_str,
"type": "authorized_user",
}
# Store email if we got it (not part of standard token format but useful)
if email:
token_data["account"] = email
saved_path = _save_credentials(token_data)
logger.info("Google Workspace credentials saved to %s (account: %s)", saved_path, email or "unknown")
return token_data
# =============================================================================
# Auth status
# =============================================================================
def get_auth_status() -> dict:
"""Return a status dict describing the current Google Workspace auth state.
Returns:
Dict with keys:
- logged_in (bool): Whether valid credentials exist
- email (str): The account email if available
- token_path (str): Path to the token file
- scopes (list): Scopes the token was granted for
- expiry (str): Token expiry timestamp
- has_refresh_token (bool): Whether a refresh token is stored
- client_source (str): Where the client credentials came from
"""
path = credentials_path()
status: Dict[str, Any] = {
"logged_in": False,
"email": "",
"token_path": str(path),
"scopes": [],
"expiry": "",
"has_refresh_token": False,
"client_source": "none",
}
# Determine client credential source
env_id = (os.getenv(ENV_CLIENT_ID) or "").strip()
if env_id:
status["client_source"] = "environment"
elif _client_secret_path().exists():
status["client_source"] = "client_secret_file"
elif _NOUS_CLIENT_ID != "PLACEHOLDER.apps.googleusercontent.com":
status["client_source"] = "nous_default"
else:
status["client_source"] = "placeholder"
token_data = load_credentials()
if token_data is None:
return status
status["email"] = token_data.get("account", "")
status["scopes"] = token_data.get("scopes", [])
status["expiry"] = token_data.get("expiry", "")
status["has_refresh_token"] = bool(token_data.get("refresh_token"))
# Check if we're actually logged in (token valid or refreshable)
status["logged_in"] = check_auth()
return status
# =============================================================================
# Revocation
# =============================================================================
def revoke() -> bool:
"""Revoke the Google Workspace token and delete the local file.
Attempts to revoke the token with Google's servers first, then deletes
the local token file regardless of whether remote revocation succeeded.
Returns:
True if the token was successfully revoked (or no token existed),
False if remote revocation failed (local file is still deleted).
"""
token_data = load_credentials()
if token_data is None:
logger.info("No Google Workspace token to revoke.")
return True
revoked_remotely = False
# Try to revoke with Google
token_to_revoke = token_data.get("token") or token_data.get("refresh_token", "")
if token_to_revoke:
try:
data = {"token": token_to_revoke}
body = urllib.parse.urlencode(data).encode("ascii")
request = urllib.request.Request(
REVOKE_ENDPOINT,
data=body,
method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
with urllib.request.urlopen(request, timeout=TOKEN_REQUEST_TIMEOUT_SECONDS) as response:
if response.status == 200:
revoked_remotely = True
logger.info("Google Workspace token revoked with Google.")
except Exception as exc:
logger.warning("Remote token revocation failed (token may already be invalid): %s", exc)
# Delete local file regardless
path = credentials_path()
with _credentials_lock():
try:
path.unlink()
logger.info("Deleted Google Workspace token at %s", path)
except FileNotFoundError:
pass
except OSError as exc:
logger.warning("Failed to delete token file at %s: %s", path, exc)
return revoked_remotely