mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-10 20:29:00 +08:00
Compare commits
2 Commits
dependabot
...
austin/fea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
290acdb59c | ||
|
|
b9d541ecb8 |
1188
agent/google_workspace_oauth.py
Normal file
1188
agent/google_workspace_oauth.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,7 @@ DEFAULT_SPOTIFY_SCOPE = " ".join((
|
||||
))
|
||||
SERVICE_PROVIDER_NAMES: Dict[str, str] = {
|
||||
"spotify": "Spotify",
|
||||
"google-workspace": "Google Workspace",
|
||||
}
|
||||
|
||||
# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)
|
||||
@@ -1804,6 +1805,138 @@ def get_gemini_oauth_auth_status() -> Dict[str, Any]:
|
||||
"email": creds.email,
|
||||
"project_id": creds.project_id,
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Google Workspace OAuth — PKCE flow for Gmail, Calendar, Drive, Sheets, Docs.
|
||||
#
|
||||
# Tokens live in ~/.hermes/google_token.json (same file the google-workspace
|
||||
# skill reads). This is a *service* provider, not an inference provider —
|
||||
# it gives the agent access to the user's personal Google data.
|
||||
# =============================================================================
|
||||
|
||||
def get_google_workspace_auth_status() -> Dict[str, Any]:
|
||||
"""Return status dict for `hermes auth status google-workspace`."""
|
||||
try:
|
||||
from agent.google_workspace_oauth import get_auth_status as _gws_status
|
||||
except ImportError:
|
||||
return {"logged_in": False, "error": "agent.google_workspace_oauth unavailable"}
|
||||
return _gws_status()
|
||||
|
||||
|
||||
def login_google_workspace_command(args) -> None:
|
||||
"""Run interactive Google Workspace PKCE login."""
|
||||
try:
|
||||
from agent.google_workspace_oauth import (
|
||||
run_oauth_login,
|
||||
run_oauth_login_headless,
|
||||
exchange_code,
|
||||
credentials_path,
|
||||
get_client_credentials,
|
||||
GoogleWorkspaceOAuthError,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise SystemExit(f"Google Workspace OAuth module not available: {exc}")
|
||||
|
||||
open_browser = not getattr(args, "no_browser", False)
|
||||
|
||||
# Check if we can resolve client credentials at all
|
||||
try:
|
||||
client_id, _ = get_client_credentials()
|
||||
except GoogleWorkspaceOAuthError as exc:
|
||||
print(f"Error: {exc}")
|
||||
print()
|
||||
print("To use Google Workspace, provide OAuth client credentials via one of:")
|
||||
print(" 1. Set HERMES_GOOGLE_WORKSPACE_CLIENT_ID and HERMES_GOOGLE_WORKSPACE_CLIENT_SECRET env vars")
|
||||
print(" 2. Place a google_client_secret.json in ~/.hermes/")
|
||||
print(" 3. Wait for the bundled Nous Research app (coming soon)")
|
||||
raise SystemExit(1)
|
||||
|
||||
if _is_remote_session() or not open_browser:
|
||||
# Headless flow — print URL for user to visit manually
|
||||
auth_url, state, code_verifier = run_oauth_login_headless()
|
||||
print("Google Workspace PKCE login (headless mode)")
|
||||
print()
|
||||
print("Open this URL in your browser:")
|
||||
print(auth_url)
|
||||
print()
|
||||
print("After authorizing, you'll be redirected to a page that may show an error.")
|
||||
print("Copy the ENTIRE URL from your browser's address bar and paste it here:")
|
||||
print()
|
||||
try:
|
||||
callback_input = input("Paste URL or code: ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print("\nAborted.")
|
||||
raise SystemExit(1)
|
||||
if not callback_input:
|
||||
print("No input provided.")
|
||||
raise SystemExit(1)
|
||||
|
||||
# Extract code from URL if user pasted the full redirect URL
|
||||
code = callback_input
|
||||
returned_state = None
|
||||
if callback_input.startswith("http"):
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
parsed = urlparse(callback_input)
|
||||
params = parse_qs(parsed.query)
|
||||
if "error" in params:
|
||||
print(f"Error from Google: {params['error'][0]}")
|
||||
raise SystemExit(1)
|
||||
if "code" not in params:
|
||||
print("Error: No 'code' parameter found in the pasted URL.")
|
||||
raise SystemExit(1)
|
||||
code = params["code"][0]
|
||||
returned_state = params.get("state", [None])[0]
|
||||
|
||||
# Validate state if available
|
||||
if returned_state and returned_state != state:
|
||||
print("Error: OAuth state mismatch. This may be a code from a different session.")
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
token_data = exchange_code(code, state, code_verifier, redirect_uri="http://localhost:1")
|
||||
except GoogleWorkspaceOAuthError as exc:
|
||||
print(f"Error: {exc}")
|
||||
raise SystemExit(1)
|
||||
else:
|
||||
# Interactive flow — opens browser, waits for callback
|
||||
print("Starting Google Workspace PKCE login...")
|
||||
print(f" Client ID: {client_id[:8]}…")
|
||||
print(f" Token will be saved to: {credentials_path()}")
|
||||
print()
|
||||
try:
|
||||
token_data = run_oauth_login()
|
||||
except GoogleWorkspaceOAuthError as exc:
|
||||
print(f"Error: {exc}")
|
||||
raise SystemExit(1)
|
||||
|
||||
print()
|
||||
print("✓ Google Workspace login successful!")
|
||||
print(f" Token saved to: {credentials_path()}")
|
||||
scopes = token_data.get("scopes", [])
|
||||
if scopes:
|
||||
print(f" Scopes: {len(scopes)} granted")
|
||||
|
||||
|
||||
def logout_google_workspace_command(args) -> None:
|
||||
"""Revoke and clear Google Workspace credentials."""
|
||||
try:
|
||||
from agent.google_workspace_oauth import revoke, credentials_path
|
||||
except ImportError as exc:
|
||||
raise SystemExit(f"Google Workspace OAuth module not available: {exc}")
|
||||
|
||||
token_path = credentials_path()
|
||||
if not token_path.exists():
|
||||
print("Google Workspace: not logged in (no token file).")
|
||||
return
|
||||
|
||||
success = revoke()
|
||||
if success:
|
||||
print("✓ Google Workspace: logged out and token revoked.")
|
||||
else:
|
||||
print("Google Workspace: token file removed (remote revocation may have failed).")
|
||||
|
||||
|
||||
# Spotify auth — PKCE tokens stored in ~/.hermes/auth.json
|
||||
# =============================================================================
|
||||
|
||||
@@ -4027,6 +4160,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
|
||||
target = provider_id or get_active_provider()
|
||||
if target == "spotify":
|
||||
return get_spotify_auth_status()
|
||||
if target == "google-workspace":
|
||||
return get_google_workspace_auth_status()
|
||||
if target == "nous":
|
||||
return get_nous_auth_status()
|
||||
if target == "openai-codex":
|
||||
|
||||
@@ -160,6 +160,15 @@ def _format_exhausted_status(entry) -> str:
|
||||
|
||||
def auth_add_command(args) -> None:
|
||||
provider = _normalize_provider(getattr(args, "provider", ""))
|
||||
|
||||
# Service providers (not inference) — redirect to their dedicated login flow
|
||||
if provider == "google-workspace":
|
||||
auth_mod.login_google_workspace_command(args)
|
||||
return
|
||||
if provider == "spotify":
|
||||
auth_mod.login_spotify_command(args)
|
||||
return
|
||||
|
||||
if provider not in PROVIDER_REGISTRY and provider != "openrouter" and not provider.startswith(CUSTOM_POOL_PREFIX):
|
||||
raise SystemExit(f"Unknown provider: {provider}")
|
||||
|
||||
@@ -508,6 +517,20 @@ def auth_spotify_command(args) -> None:
|
||||
raise SystemExit(f"Unknown Spotify auth action: {action}")
|
||||
|
||||
|
||||
def auth_google_workspace_command(args) -> None:
|
||||
action = str(getattr(args, "gws_action", "") or "login").strip().lower()
|
||||
if action in {"", "login"}:
|
||||
auth_mod.login_google_workspace_command(args)
|
||||
return
|
||||
if action == "status":
|
||||
auth_status_command(SimpleNamespace(provider="google-workspace"))
|
||||
return
|
||||
if action == "logout":
|
||||
auth_mod.logout_google_workspace_command(args)
|
||||
return
|
||||
raise SystemExit(f"Unknown Google Workspace auth action: {action}")
|
||||
|
||||
|
||||
def _interactive_auth() -> None:
|
||||
"""Interactive credential pool management when `hermes auth` is called bare."""
|
||||
# Show current pool status first
|
||||
@@ -714,5 +737,8 @@ def auth_command(args) -> None:
|
||||
if action == "spotify":
|
||||
auth_spotify_command(args)
|
||||
return
|
||||
if action == "google-workspace":
|
||||
auth_google_workspace_command(args)
|
||||
return
|
||||
# No subcommand — launch interactive mode
|
||||
_interactive_auth()
|
||||
|
||||
@@ -9032,6 +9032,22 @@ def main():
|
||||
auth_spotify.add_argument(
|
||||
"--timeout", type=float, help="Callback/token exchange timeout in seconds"
|
||||
)
|
||||
|
||||
auth_gws = auth_subparsers.add_parser(
|
||||
"google-workspace", help="Authenticate Hermes with Google Workspace (Gmail, Calendar, Drive, Sheets, Docs)"
|
||||
)
|
||||
auth_gws.add_argument(
|
||||
"gws_action",
|
||||
nargs="?",
|
||||
choices=["login", "status", "logout"],
|
||||
default="login",
|
||||
)
|
||||
auth_gws.add_argument(
|
||||
"--no-browser",
|
||||
action="store_true",
|
||||
help="Do not attempt to open the browser automatically (headless mode)",
|
||||
)
|
||||
|
||||
auth_parser.set_defaults(func=cmd_auth)
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -10,6 +10,9 @@ Usage:
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import hashlib
|
||||
import html
|
||||
import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
@@ -226,6 +229,9 @@ async def auth_middleware(request: Request, call_next):
|
||||
"""Require the session token on all /api/ routes except the public list."""
|
||||
path = request.url.path
|
||||
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"):
|
||||
# Google Workspace OAuth start + poll are safe without auth (CLI access)
|
||||
if path == "/api/providers/oauth/google-workspace/start" or path.startswith("/api/providers/oauth/google-workspace/poll/"):
|
||||
return await call_next(request)
|
||||
if not _has_valid_session_token(request):
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
@@ -1462,6 +1468,14 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
"docs_url": "https://www.minimax.io",
|
||||
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
|
||||
},
|
||||
{
|
||||
"id": "google-workspace",
|
||||
"name": "Google Workspace",
|
||||
"flow": "pkce",
|
||||
"cli_command": "hermes auth google-workspace login",
|
||||
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/features/google-workspace",
|
||||
"status_fn": None, # dispatched via auth.get_google_workspace_auth_status
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -1515,6 +1529,16 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": True,
|
||||
}
|
||||
if provider_id == "google-workspace":
|
||||
raw = hauth.get_google_workspace_auth_status()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": "google_workspace_oauth",
|
||||
"source_label": raw.get("email") or "Google Workspace",
|
||||
"token_preview": None,
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
@@ -1585,6 +1609,15 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
_log.info("oauth/disconnect: %s", provider_id)
|
||||
return {"ok": True, "provider": provider_id}
|
||||
|
||||
if provider_id == "google-workspace":
|
||||
try:
|
||||
from agent.google_workspace_oauth import revoke
|
||||
revoke()
|
||||
except Exception:
|
||||
pass
|
||||
_log.info("oauth/disconnect: %s", provider_id)
|
||||
return {"ok": True, "provider": provider_id}
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import clear_provider_auth
|
||||
cleared = clear_provider_auth(provider_id)
|
||||
@@ -2097,10 +2130,167 @@ def _codex_full_login_worker(session_id: str) -> None:
|
||||
s["error_message"] = str(e)
|
||||
|
||||
|
||||
def _start_google_workspace_pkce() -> Dict[str, Any]:
|
||||
"""Begin Google Workspace PKCE flow. Returns auth URL for the dashboard to open."""
|
||||
try:
|
||||
from agent.google_workspace_oauth import (
|
||||
get_client_credentials,
|
||||
GoogleWorkspaceOAuthError,
|
||||
AUTH_ENDPOINT,
|
||||
SCOPES,
|
||||
)
|
||||
except ImportError:
|
||||
raise HTTPException(status_code=501, detail="Google Workspace OAuth module not available")
|
||||
|
||||
try:
|
||||
client_id, client_secret = get_client_credentials()
|
||||
except GoogleWorkspaceOAuthError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
# Generate PKCE pair
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
|
||||
|
||||
sid, sess = _new_oauth_session("google-workspace", "pkce")
|
||||
sess["verifier"] = verifier
|
||||
sess["client_id"] = client_id
|
||||
sess["client_secret"] = client_secret
|
||||
|
||||
# Use localhost:9119 callback so the dashboard catches the redirect automatically
|
||||
# Use the actual bound port if available, default to 9119
|
||||
bound_port = getattr(app.state, "bound_port", 9119)
|
||||
redirect_uri = f"http://localhost:{bound_port}/auth/google/callback"
|
||||
sess["redirect_uri"] = redirect_uri
|
||||
|
||||
params = {
|
||||
"client_id": client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": " ".join(SCOPES),
|
||||
"state": sid, # Use session_id as state for easy lookup on callback
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"access_type": "offline",
|
||||
"prompt": "consent",
|
||||
}
|
||||
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "pkce",
|
||||
"auth_url": auth_url,
|
||||
"expires_in": _OAUTH_SESSION_TTL_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
def _submit_google_workspace_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
||||
"""Exchange Google Workspace authorization code for tokens. Saves on success."""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess or sess["provider"] != "google-workspace" or sess["flow"] != "pkce":
|
||||
raise HTTPException(status_code=404, detail="Unknown or expired session")
|
||||
if sess["status"] != "pending":
|
||||
return {"ok": False, "status": sess["status"], "message": sess.get("error_message")}
|
||||
|
||||
code = code_input.strip()
|
||||
if not code:
|
||||
return {"ok": False, "status": "error", "message": "No code provided"}
|
||||
|
||||
# Exchange code for tokens
|
||||
try:
|
||||
from agent.google_workspace_oauth import (
|
||||
TOKEN_ENDPOINT,
|
||||
SCOPES,
|
||||
credentials_path,
|
||||
)
|
||||
except ImportError:
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = "Google Workspace OAuth module not available"
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
|
||||
exchange_data = urllib.parse.urlencode({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": sess["client_id"],
|
||||
"client_secret": sess["client_secret"],
|
||||
"code": code,
|
||||
"redirect_uri": sess["redirect_uri"],
|
||||
"code_verifier": sess["verifier"],
|
||||
}).encode()
|
||||
|
||||
req = urllib.request.Request(
|
||||
TOKEN_ENDPOINT,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "hermes-dashboard/1.0",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = f"Token exchange failed: {e}"
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
|
||||
access_token = result.get("access_token", "")
|
||||
refresh_token = result.get("refresh_token", "")
|
||||
expires_in = int(result.get("expires_in") or 3600)
|
||||
if not access_token:
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = "No access token returned"
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
|
||||
if not refresh_token:
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = "No refresh token returned. Ensure 'access_type=offline' and 'prompt=consent' are set."
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
|
||||
# Save token in google_token.json format (compatible with google-workspace skill)
|
||||
from datetime import datetime, timezone
|
||||
expiry = datetime.fromtimestamp(
|
||||
datetime.now(timezone.utc).timestamp() + expires_in,
|
||||
tz=timezone.utc,
|
||||
).isoformat()
|
||||
|
||||
token_payload = {
|
||||
"token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_uri": TOKEN_ENDPOINT,
|
||||
"client_id": sess["client_id"],
|
||||
"client_secret": sess["client_secret"],
|
||||
"scopes": SCOPES,
|
||||
"expiry": expiry,
|
||||
"type": "authorized_user",
|
||||
}
|
||||
|
||||
token_path = credentials_path()
|
||||
token_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
import os as _os
|
||||
fd = _os.open(str(token_path), _os.O_WRONLY | _os.O_CREAT | _os.O_TRUNC, 0o600)
|
||||
try:
|
||||
_os.write(fd, json.dumps(token_payload, indent=2).encode("utf-8"))
|
||||
finally:
|
||||
_os.close(fd)
|
||||
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/pkce: google-workspace login completed (session=%s)", session_id)
|
||||
return {"ok": True, "status": "approved"}
|
||||
|
||||
|
||||
@app.post("/api/providers/oauth/{provider_id}/start")
|
||||
async def start_oauth_login(provider_id: str, request: Request):
|
||||
"""Initiate an OAuth login flow. Token-protected."""
|
||||
_require_token(request)
|
||||
"""Initiate an OAuth login flow. Token-protected (except google-workspace for CLI access)."""
|
||||
# Google Workspace start is safe without auth — it only generates a consent URL.
|
||||
# This lets the CLI call it without knowing the ephemeral dashboard token.
|
||||
if provider_id != "google-workspace":
|
||||
_require_token(request)
|
||||
_gc_oauth_sessions()
|
||||
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||
if provider_id not in valid:
|
||||
@@ -2113,6 +2303,11 @@ async def start_oauth_login(provider_id: str, request: Request):
|
||||
)
|
||||
try:
|
||||
if catalog_entry["flow"] == "pkce":
|
||||
if provider_id == "anthropic":
|
||||
return _start_anthropic_pkce()
|
||||
if provider_id == "google-workspace":
|
||||
return _start_google_workspace_pkce()
|
||||
# Fallback (minimax, etc.) — uses anthropic-style PKCE
|
||||
return _start_anthropic_pkce()
|
||||
if catalog_entry["flow"] == "device_code":
|
||||
return await _start_device_code_flow(provider_id)
|
||||
@@ -2137,6 +2332,10 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, _submit_anthropic_pkce, body.session_id, body.code,
|
||||
)
|
||||
if provider_id == "google-workspace":
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, _submit_google_workspace_pkce, body.session_id, body.code,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
|
||||
|
||||
|
||||
@@ -2157,6 +2356,160 @@ async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
}
|
||||
|
||||
|
||||
@app.get("/auth/google/callback")
|
||||
async def google_oauth_callback(request: Request):
|
||||
"""Handle the Google OAuth redirect.
|
||||
|
||||
Google redirects here after the user authorizes. We extract the code,
|
||||
exchange it for tokens via the matching session, and return an HTML page
|
||||
that says "Connected! You can close this tab."
|
||||
"""
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
params = dict(request.query_params)
|
||||
code = params.get("code", "")
|
||||
state = params.get("state", "") # This is the session_id
|
||||
error = params.get("error", "")
|
||||
|
||||
if error:
|
||||
return HTMLResponse(
|
||||
f"""<html>
|
||||
<head><meta charset="utf-8"><title>Authorization 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,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>Authorization Failed</h1><p>{html.escape(error)}</p></div></body>
|
||||
</html>""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
if not code or not state:
|
||||
return HTMLResponse(
|
||||
"""<html>
|
||||
<head><meta charset="utf-8"><title>Error — 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,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>Error</h1><p>Missing code or state parameter.</p></div></body>
|
||||
</html>""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
# Exchange the code using the session
|
||||
try:
|
||||
result = _submit_google_workspace_pkce(state, code)
|
||||
except HTTPException as exc:
|
||||
return HTMLResponse(
|
||||
f"<html><body><h2>❌ {html.escape(str(exc.detail))}</h2></body></html>",
|
||||
status_code=exc.status_code,
|
||||
)
|
||||
|
||||
if result.get("ok"):
|
||||
return HTMLResponse(
|
||||
"""<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>"""
|
||||
)
|
||||
else:
|
||||
msg = result.get("message", "Unknown error")
|
||||
return HTMLResponse(
|
||||
f"""<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>{html.escape(str(msg))}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>""",
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
||||
async def cancel_oauth_session(session_id: str, request: Request):
|
||||
"""Cancel a pending OAuth session. Token-protected."""
|
||||
|
||||
@@ -43,7 +43,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
// Google Workspace PKCE uses a server-side callback — poll like device_code
|
||||
const usePolling =
|
||||
resp.flow === "device_code" ||
|
||||
(resp.flow === "pkce" && provider.id === "google-workspace");
|
||||
setPhase(usePolling ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
@@ -80,9 +84,12 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
|
||||
return () => window.clearInterval(tick);
|
||||
}, [secondsLeft, phase, t]);
|
||||
|
||||
// Device-code: poll backend every 2s
|
||||
// Device-code or server-callback PKCE (Google Workspace): poll backend every 2s
|
||||
useEffect(() => {
|
||||
if (!start || start.flow !== "device_code" || phase !== "polling") return;
|
||||
if (!start || phase !== "polling") return;
|
||||
const isDeviceCode = start.flow === "device_code";
|
||||
const isCallbackPkce = start.flow === "pkce" && provider.id === "google-workspace";
|
||||
if (!isDeviceCode && !isCallbackPkce) return;
|
||||
const sid = start.session_id;
|
||||
pollTimer.current = window.setInterval(async () => {
|
||||
try {
|
||||
|
||||
@@ -55,6 +55,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
||||
const [disconnectFor, setDisconnectFor] = useState<OAuthProvider | null>(null);
|
||||
const { t } = useI18n();
|
||||
|
||||
const onErrorRef = useRef(onError);
|
||||
@@ -74,9 +75,13 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
}, [refresh]);
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
|
||||
return;
|
||||
}
|
||||
setDisconnectFor(provider);
|
||||
};
|
||||
|
||||
const confirmDisconnect = async () => {
|
||||
if (!disconnectFor) return;
|
||||
const provider = disconnectFor;
|
||||
setDisconnectFor(null);
|
||||
setBusyId(provider.id);
|
||||
try {
|
||||
await api.disconnectOAuthProvider(provider.id);
|
||||
@@ -266,6 +271,39 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
onError={(msg) => onError?.(msg)}
|
||||
/>
|
||||
)}
|
||||
{disconnectFor && (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) setDisconnectFor(null); }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="relative w-full max-w-sm border border-border bg-card shadow-2xl p-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold uppercase tracking-wide">
|
||||
{t.oauth.disconnect}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Disconnect <strong>{disconnectFor.name}</strong> from Hermes Agent? You can reconnect anytime.
|
||||
</p>
|
||||
<div className="flex gap-3 justify-end pt-2">
|
||||
<Button
|
||||
ghost
|
||||
onClick={() => setDisconnectFor(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={confirmDisconnect}
|
||||
className="bg-red-900/50 border-red-700/50 hover:bg-red-800/60 text-red-200"
|
||||
>
|
||||
Disconnect
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user