mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-08 02:37:45 +02:00
gmail: route OAuth refresh through cloud rotation-pool proxy
google-workspace-mcp's gauth.py hardcodes token_uri to oauth2.googleapis.com and uses local CLIENT_ID/SECRET on every refresh. OAuth runs through a rotation pool on the cloud side, so the refresh_token is bound to the pool slot that minted it, not the single client baked into the DMG, and direct refreshes return unauthorized_client. Redirect spawn through a shim that monkey-patches get_credentials to point token_uri at a local proxy (/api/tools/google-oauth-token), which forwards the refresh to api.openswarm.com/api/oauth/google/refresh (pool-aware). Unblocks every Gmail/Drive/Calendar query for users on the cloud OAuth pool.
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
"""Run google-workspace-mcp's stdio worker with token refresh redirected
|
||||
through our local proxy instead of directly to oauth2.googleapis.com.
|
||||
|
||||
google_workspace_mcp.auth.gauth.get_credentials() hardcodes
|
||||
token_uri="https://oauth2.googleapis.com/token" and refreshes with
|
||||
whatever GOOGLE_WORKSPACE_CLIENT_ID/SECRET are in env on every API call.
|
||||
OAuth runs through a rotation pool in openswarm-cloud, so the
|
||||
refresh_token is bound to whichever pool slot minted it, not the single
|
||||
client baked into the DMG. Refresh directly against Google with the
|
||||
wrong client returns unauthorized_client.
|
||||
|
||||
This wrapper monkey-patches gauth.get_credentials before the worker
|
||||
imports its tool modules, pointing token_uri at GOOGLE_WORKSPACE_TOKEN_URI
|
||||
(our local proxy at /api/tools/google-oauth-token, which forwards refresh
|
||||
requests to the cloud's pool-aware /api/oauth/google/refresh).
|
||||
CLIENT_ID/SECRET become unused placeholders.
|
||||
"""
|
||||
|
||||
import functools
|
||||
import os
|
||||
|
||||
import google_workspace_mcp.auth.gauth as gauth
|
||||
from google.oauth2.credentials import Credentials
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _patched_get_credentials():
|
||||
refresh_token = os.environ.get("GOOGLE_WORKSPACE_REFRESH_TOKEN")
|
||||
if not refresh_token:
|
||||
raise ValueError("GOOGLE_WORKSPACE_REFRESH_TOKEN env var is required")
|
||||
return Credentials(
|
||||
token=None,
|
||||
refresh_token=refresh_token,
|
||||
token_uri=os.environ.get(
|
||||
"GOOGLE_WORKSPACE_TOKEN_URI",
|
||||
"https://oauth2.googleapis.com/token",
|
||||
),
|
||||
client_id=os.environ.get("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy"),
|
||||
client_secret=os.environ.get("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy"),
|
||||
)
|
||||
|
||||
|
||||
gauth.get_credentials = _patched_get_credentials
|
||||
|
||||
|
||||
from google_workspace_mcp import __main__ as _gw_main # noqa: E402,F401
|
||||
from google_workspace_mcp.app import mcp # noqa: E402
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Upstream google_workspace_mcp.__main__.main() wraps a synchronous
|
||||
# mcp.run() in asyncio.run() which throws "a coroutine was expected,
|
||||
# got None" against current FastMCP. Skip it and invoke FastMCP's
|
||||
# stdio loop directly. The `_gw_main` import above is what actually
|
||||
# registers every tool/prompt/resource module against the shared
|
||||
# `mcp` instance via its top-level imports.
|
||||
mcp.run("stdio")
|
||||
@@ -13,7 +13,7 @@ from urllib.parse import urlencode
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import HTTPException, Query
|
||||
from fastapi import HTTPException, Query, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from backend.config.Apps import SubApp
|
||||
@@ -331,21 +331,37 @@ def derive_mcp_config(tool: ToolDefinition) -> Optional[dict]:
|
||||
env["PRIVATE_APP_ACCESS_TOKEN"] = tool.oauth_tokens["access_token"]
|
||||
if tool.oauth_tokens.get("refresh_token"):
|
||||
env["GOOGLE_WORKSPACE_REFRESH_TOKEN"] = tool.oauth_tokens["refresh_token"]
|
||||
# google_workspace_mcp's auth/gauth.py requires all three of
|
||||
# CLIENT_ID / CLIENT_SECRET / REFRESH_TOKEN at startup and does
|
||||
# its own token refresh per API call; it ignores any
|
||||
# pre-refreshed access_token. v1.0.29's cloud-proxy migration
|
||||
# closes the OAuth-flow secret exposure, but the MCP still
|
||||
# needs the local secret here. v1.0.30 should either fork the
|
||||
# MCP to point token_uri at our cloud refresh proxy, or replace
|
||||
# it with a thin in-house Gmail/Drive/Calendar wrapper that
|
||||
# consumes a pre-refreshed access_token.
|
||||
client_id = os.environ.get("GOOGLE_OAUTH_CLIENT_ID", "")
|
||||
client_secret = os.environ.get("GOOGLE_OAUTH_CLIENT_SECRET", "")
|
||||
if client_id:
|
||||
env["GOOGLE_WORKSPACE_CLIENT_ID"] = client_id
|
||||
if client_secret:
|
||||
env["GOOGLE_WORKSPACE_CLIENT_SECRET"] = client_secret
|
||||
# google_workspace_mcp's gauth.py hardcodes token_uri to
|
||||
# https://oauth2.googleapis.com/token and refreshes using the
|
||||
# local CLIENT_ID/SECRET on every API call. The OAuth flow
|
||||
# itself runs through the cloud's rotation pool, so the
|
||||
# refresh_token is bound to whichever pool slot minted it,
|
||||
# not the single client baked into the DMG. Mismatch returns
|
||||
# unauthorized_client. We point token_uri at a local proxy
|
||||
# that forwards the refresh to our cloud's pool-aware
|
||||
# /api/oauth/google/refresh endpoint; CLIENT_ID/SECRET become
|
||||
# unused placeholders (gauth.py only validates non-empty).
|
||||
_port = os.environ.get("OPENSWARM_PORT", "8324")
|
||||
env["GOOGLE_WORKSPACE_TOKEN_URI"] = (
|
||||
f"http://127.0.0.1:{_port}/api/tools/google-oauth-token"
|
||||
)
|
||||
env.setdefault("GOOGLE_WORKSPACE_CLIENT_ID", "openswarm-proxy")
|
||||
env.setdefault("GOOGLE_WORKSPACE_CLIENT_SECRET", "openswarm-proxy")
|
||||
|
||||
# Google Workspace MCP: redirect spawn through our shim that
|
||||
# monkey-patches gauth.get_credentials before the worker registers
|
||||
# tools, so token_uri points at our local proxy. Stays a stdio
|
||||
# subprocess; google-workspace-mcp gets installed into uv's
|
||||
# ephemeral env via --with, same way the upstream entry-point
|
||||
# invocation used to do it.
|
||||
if tool.name.lower() == "google workspace" and config.get("type") == "stdio":
|
||||
shim_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
||||
"google_workspace_mcp_shim",
|
||||
"run.py",
|
||||
)
|
||||
config["command"] = "uv"
|
||||
config["args"] = ["run", "--with", "google-workspace-mcp", "python", shim_path]
|
||||
|
||||
# Discord MCP runs as a small Python shim (backend.apps.discord_mcp_shim).
|
||||
# We pass install_id + base URL via env so the shim subprocess doesn't
|
||||
@@ -1330,3 +1346,60 @@ async def refresh_airtable_token(tool: ToolDefinition) -> Optional[str]:
|
||||
async def refresh_hubspot_token(tool: ToolDefinition) -> Optional[str]:
|
||||
"""Refresh an expired HubSpot OAuth access_token."""
|
||||
return await _refresh_via_proxy("hubspot", tool, default_expiry=1800)
|
||||
|
||||
|
||||
@tools_lib.router.post("/google-oauth-token")
|
||||
async def google_oauth_token_proxy(request: Request):
|
||||
"""Local mimic of Google's OAuth2 token endpoint for the
|
||||
google-workspace-mcp subprocess.
|
||||
|
||||
google-workspace-mcp's google-auth library posts form-encoded
|
||||
{grant_type, refresh_token, client_id, client_secret} on every
|
||||
expired-token refresh. Because OAuth runs through a cloud-side
|
||||
rotation pool, the local CLIENT_ID/SECRET don't match the pool slot
|
||||
that minted the refresh_token, so a direct refresh against Google
|
||||
returns unauthorized_client. We accept the form-encoded shape,
|
||||
discard the (mismatched) local client creds, and forward the
|
||||
refresh_token to api.openswarm.com/api/oauth/google/refresh which
|
||||
walks the pool to find the issuing slot. The cloud's JSON envelope
|
||||
is reshaped back to Google's native token-endpoint response so
|
||||
google-auth keeps working transparently.
|
||||
"""
|
||||
form = await request.form()
|
||||
grant_type = form.get("grant_type") or ""
|
||||
refresh_token = form.get("refresh_token") or ""
|
||||
if grant_type != "refresh_token" or not refresh_token:
|
||||
return Response(
|
||||
content='{"error":"unsupported_grant_type"}',
|
||||
status_code=400,
|
||||
media_type="application/json",
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
upstream = await client.post(
|
||||
f"{OPENSWARM_OAUTH_BASE_URL}/api/oauth/google/refresh",
|
||||
json={"refresh_token": refresh_token},
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
content=f'{{"error":"upstream_unreachable","error_description":"{e}"}}',
|
||||
status_code=502,
|
||||
media_type="application/json",
|
||||
)
|
||||
if upstream.status_code != 200:
|
||||
return Response(
|
||||
content=upstream.text,
|
||||
status_code=upstream.status_code,
|
||||
media_type="application/json",
|
||||
)
|
||||
tokens = (upstream.json() or {}).get("tokens") or {}
|
||||
return Response(
|
||||
content=json.dumps({
|
||||
"access_token": tokens.get("access_token", ""),
|
||||
"expires_in": tokens.get("expires_in", 3600),
|
||||
"scope": tokens.get("scope", ""),
|
||||
"token_type": tokens.get("token_type", "Bearer"),
|
||||
}),
|
||||
status_code=200,
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
@@ -313,6 +313,13 @@ _AUTH_EXEMPT_EXACT = {
|
||||
"/api/subscription/activate",
|
||||
"/api/auth/signin-activate",
|
||||
"/api/version",
|
||||
# Local Google OAuth token-endpoint proxy: hit by the
|
||||
# google-workspace-mcp subprocess we spawn. It doesn't (and can't
|
||||
# easily) carry the install bearer in google-auth's refresh post.
|
||||
# Localhost binding is the gate, and the route does nothing the
|
||||
# public api.openswarm.com/api/oauth/google/refresh doesn't already
|
||||
# do for any internet caller, so no new attack surface.
|
||||
"/api/tools/google-oauth-token",
|
||||
}
|
||||
|
||||
# Path prefixes that never require auth. Trailing slash optional.
|
||||
|
||||
Reference in New Issue
Block a user