From f1cf140115c8aaab5ca8fbbd37807bf8823809f8 Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Thu, 25 Jun 2026 14:06:31 +0530 Subject: [PATCH 1/3] OIDC implemented for google drive mcp Signed-off-by: gokul-aot --- config/connectors.yaml | 4 ++ docs/google_drive_connector.md | 30 +++++++++ sample.env | 3 + src/bindings/factory.py | 47 ++++++++++++- src/bindings/mcp_server/auth.py | 41 +++++++++++- src/bindings/mcp_server/server.py | 65 +++++++++++++++++- src/node_wire_google_drive/logic.py | 42 ++++++------ src/node_wire_runtime/auth/base.py | 18 +++++ tests/test_auth_providers.py | 56 ++++++++++++++++ tests/test_google_drive.py | 59 +++++++++++++++- tests/test_mcp_auth.py | 100 ++++++++++++++++++++++++++++ 11 files changed, 437 insertions(+), 28 deletions(-) diff --git a/config/connectors.yaml b/config/connectors.yaml index bc5d18e..669abe6 100644 --- a/config/connectors.yaml +++ b/config/connectors.yaml @@ -46,6 +46,10 @@ connectors: sa_json_secret: GOOGLE_DRIVE_SA_JSON scopes: - https://www.googleapis.com/auth/drive + # OIDC / per-user Drive (ToolHive upstream bearer passthrough): + # auth: + # provider: upstream_bearer + # Or: GOOGLE_DRIVE_AUTH_PROVIDER=upstream_bearer (overrides yaml when set) fhir_epic: enabled: true diff --git a/docs/google_drive_connector.md b/docs/google_drive_connector.md index b887852..2ea977b 100644 --- a/docs/google_drive_connector.md +++ b/docs/google_drive_connector.md @@ -15,6 +15,36 @@ For **MCP** (e.g. ToolHive), tools are named `google_drive.` from the co --- +## User OAuth (OIDC / upstream bearer) + +For **per-user Google Drive access** (each caller uses their own Drive), set: + +```yaml +google_drive: + auth: + provider: upstream_bearer +``` + +Or set the environment variable (overrides `connectors.yaml` when present): + +```env +GOOGLE_DRIVE_AUTH_PROVIDER=upstream_bearer +``` + +Allowed values: `service_account` (default), `upstream_bearer`. + +Run the **google-drive-only** MCP server (`python -m agents.google_drive_mcp`) with `NW_MCP_TRANSPORT=streamable-http`. The `Authorization: Bearer` token on each MCP request must be the Google access token (typically issued via ToolHive embedded OIDC). Do **not** set `NW_MCP_API_KEY`, `NW_MCP_JWT_SECRET`, or `GOOGLE_DRIVE_SA_JSON` for this profile. + +**ToolHive OIDC manifests:** copy and adapt from [mcp-builder `out/google-drive-mcp/deploy/`](https://github.com/stacklok/mcp-builder/tree/main/out/google-drive-mcp/deploy) (`mcpexternalauthconfig.yaml`, `mcpoidcconfig.yaml`, `mcpserver.yaml`) — use image/entrypoint `nw-google-drive` / `python -m agents.google_drive_mcp`. + +**Ponytail:** Passthrough MCP auth applies only when this server exposes `google_drive` alone with `upstream_bearer`. The unified `mcp_entrypoint` with multiple connectors keeps API-key/JWT MCP auth. + +With `NW_MCP_SCOPE_POLICY_DEFAULT=deny` (recommended for production), the google-drive MCP server auto-grants the per-action MCP scopes (`mcp:google_drive.`) from its manifest to upstream bearer callers so `tools/list` is not empty. Google OAuth on the `Authorization: Bearer` token remains the boundary for Drive API access—refresh that access token when Drive calls fail with auth errors. + +For **shared-folder automation** (single service identity), keep the [service account setup](#google-drive-service-account-setup) below. + +--- + ## Google Drive service account setup This guide walks you through creating a Google Cloud service account and connecting it to Node Wire. A service account is a special type of Google account used by applications (rather than humans) to authenticate with Google APIs. diff --git a/sample.env b/sample.env index a09c535..2552a59 100644 --- a/sample.env +++ b/sample.env @@ -20,6 +20,9 @@ CERNER_SCOPES="system/Patient.read system/Encounter.read system/DocumentReferenc # Google Drive GOOGLE_DRIVE_SA_JSON=/absolute/path/to/service-account.json GOOGLE_DRIVE_FOLDER_ID=your-google-drive-folder-id +# Auth profile: service_account (default) or upstream_bearer (MCP OIDC per-user Drive) +# GOOGLE_DRIVE_AUTH_PROVIDER=service_account +# GOOGLE_DRIVE_AUTH_PROVIDER=upstream_bearer # SMTP SMTP_HOST=smtp.gmail.com diff --git a/src/bindings/factory.py b/src/bindings/factory.py index fc63426..1395d31 100644 --- a/src/bindings/factory.py +++ b/src/bindings/factory.py @@ -28,6 +28,24 @@ _PLATFORM_ROOT = Path(__file__).resolve().parent.parent.parent _DEFAULT_CONFIG_PATH = _PLATFORM_ROOT / "config" / "connectors.yaml" +_GOOGLE_DRIVE_AUTH_PROVIDER_ENV = "GOOGLE_DRIVE_AUTH_PROVIDER" +_GOOGLE_DRIVE_AUTH_PROVIDERS = frozenset({"service_account", "upstream_bearer"}) + + +def _resolve_google_drive_auth(auth_cfg: dict[str, Any]) -> dict[str, Any]: + """Apply GOOGLE_DRIVE_AUTH_PROVIDER env override when set (wins over connectors.yaml).""" + override = os.environ.get(_GOOGLE_DRIVE_AUTH_PROVIDER_ENV, "").strip() + if not override: + return auth_cfg + if override not in _GOOGLE_DRIVE_AUTH_PROVIDERS: + raise ValueError( + f"{_GOOGLE_DRIVE_AUTH_PROVIDER_ENV} must be one of " + f"{sorted(_GOOGLE_DRIVE_AUTH_PROVIDERS)!r}, got {override!r}" + ) + merged = dict(auth_cfg) + merged["provider"] = override + return merged + def _resolve_env_vars(data: Any) -> Any: if isinstance(data, dict): @@ -174,12 +192,15 @@ def load(self) -> None: for connector_id, cfg in connectors_cfg.items(): enabled = bool(cfg.get("enabled", False)) exposed_via = list(cfg.get("exposed_via", [])) + cfg_raw: Dict[str, Any] = dict(cfg) + if connector_id == "google_drive": + cfg_raw["auth"] = _resolve_google_drive_auth(cfg_raw.get("auth") or {}) self._configs[connector_id] = ConnectorConfig( id=connector_id, enabled=enabled, exposed_via=exposed_via, - raw=cfg, + raw=cfg_raw, ) if not enabled: @@ -215,6 +236,8 @@ def _build_auth_provider(self, connector_id: str, cfg: dict) -> Any: ) auth_cfg = cfg.get("auth") or {} + if connector_id == "google_drive": + auth_cfg = _resolve_google_drive_auth(auth_cfg) provider_type = auth_cfg.get("provider", "none") if provider_type in ("none", ""): @@ -254,6 +277,28 @@ def _build_auth_provider(self, connector_id: str, cfg: dict) -> Any: scopes=auth_cfg.get("scopes"), ) + if provider_type == "upstream_bearer": + from node_wire_runtime.auth.base import AuthProvider, get_upstream_bearer + + class _UpstreamBearerProvider(AuthProvider): # type: ignore[misc] + per_request_credentials = True + + async def get_headers(self) -> dict: + token = get_upstream_bearer() + if not token: + raise RuntimeError("Upstream bearer token required") + return {"Authorization": f"Bearer {token}"} + + async def get_client_credentials(self): # type: ignore[override] + from google.oauth2.credentials import Credentials # type: ignore[import] + + token = get_upstream_bearer() + if not token: + return None + return Credentials(token=token) + + return _UpstreamBearerProvider() + if provider_type == "static_credentials": # SMTP-style: returns (username, password) tuple via get_client_credentials(). # We use a lightweight wrapper around StaticTokenAuthProvider pair. diff --git a/src/bindings/mcp_server/auth.py b/src/bindings/mcp_server/auth.py index 5f22dcc..95a3867 100644 --- a/src/bindings/mcp_server/auth.py +++ b/src/bindings/mcp_server/auth.py @@ -4,6 +4,7 @@ # from __future__ import annotations +import contextvars import os import logging from pathlib import Path @@ -19,8 +20,13 @@ decode_binding_jwt, parse_api_key_scopes_from_env, ) +from node_wire_runtime.auth.base import reset_upstream_bearer, set_upstream_bearer -logger = logging.getLogger("bindings.mcp_server.auth") +logger = logging.getLogger(__name__) + +_upstream_reset_ctx: contextvars.ContextVar[contextvars.Token | None] = contextvars.ContextVar( + "_mcp_upstream_reset", default=None +) # Back-compat: callers may still import ``McpIdentity`` / ``build_identity`` from MCP auth. McpIdentity = CallerIdentity @@ -227,7 +233,32 @@ def authenticate_mcp_request( *, headers: Mapping[str, Any] | None = None, meta: Mapping[str, Any] | None = None, + upstream_passthrough: bool = False, + upstream_granted_scopes: tuple[str, ...] = (), ) -> CallerIdentity | None: + if upstream_passthrough: + if mcp_auth_disabled(): + return None + token = extract_token(headers=headers, meta=meta) + if not token: + raise McpAuthRequiredError() + _upstream_reset_ctx.set(set_upstream_bearer(token)) + # Ponytail: MCP scopes gate tool visibility on this server; the Google OAuth + # access token on the request is the upstream authz boundary for Drive API. + identity = build_caller_identity( + { + "sub": "upstream-bearer", + "tenant_id": None, + "scopes": list(upstream_granted_scopes), + }, + "upstream_bearer", + ) + logger.info( + "MCP upstream passthrough accepted", + extra={"auth_type": identity.auth_type, "principal": identity.principal}, + ) + return identity + logger.info( "MCP auth gate status", extra={ @@ -259,3 +290,11 @@ def authenticate_mcp_request( }, ) return identity + + +def reset_upstream_passthrough_context() -> None: + """Clear upstream bearer set during passthrough auth (call in middleware finally).""" + reset_tok = _upstream_reset_ctx.get() + if reset_tok is not None: + reset_upstream_bearer(reset_tok) + _upstream_reset_ctx.set(None) diff --git a/src/bindings/mcp_server/server.py b/src/bindings/mcp_server/server.py index ba7d1eb..ed1104b 100644 --- a/src/bindings/mcp_server/server.py +++ b/src/bindings/mcp_server/server.py @@ -13,9 +13,10 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple from bindings.factory import ConnectorFactory -from bindings.mcp_server.auth import ( - McpAuthError, +from bindings.mcp_server.auth import ( + McpAuthError, authenticate_mcp_request, + reset_upstream_passthrough_context, log_effective_mcp_auth_state, ) from node_wire_runtime.caller_identity import CallerIdentity @@ -23,6 +24,7 @@ action_allowed_for_identity_scopes, load_scope_map_from_env, load_scope_policy_default_from_env, + resolve_required_scope_for_action, ) from node_wire_runtime.connector_registry import auto_register from node_wire_runtime.manifest import MCP_MANIFEST_CONTRACT_VERSION, build_manifest @@ -115,6 +117,45 @@ def _process_response_payload(data: Any, max_items: int) -> Tuple[Any, bool, int return data, False, 0, next_page_token +def _resolve_upstream_passthrough( + factory: ConnectorFactory, + connector_ids: frozenset[str] | None, +) -> bool: + """Enable when google_drive-only MCP server uses upstream_bearer auth.""" + if connector_ids != frozenset({"google_drive"}): + return False + cfg = factory._configs.get("google_drive") + if cfg is None: + return False + auth = cfg.raw.get("auth") or {} + return auth.get("provider") == "upstream_bearer" + + +def _upstream_passthrough_scopes( + factory: ConnectorFactory, + connector_ids: frozenset[str] | None, +) -> tuple[str, ...]: + if connector_ids is None: + return () + scope_map = load_scope_map_from_env() + default_mode = load_scope_policy_default_from_env() + manifest = build_manifest(factory.list_for_protocol("mcp")) + scopes: set[str] = set() + for entry in manifest: + cid = entry["connector_id"] + if cid not in connector_ids: + continue + required = resolve_required_scope_for_action( + connector_id=cid, + action=str(entry["action"]), + action_scope_map=scope_map, + default_mode=default_mode, + ) + if required: + scopes.add(required) + return tuple(sorted(scopes)) + + class McpServer: """ Manifest-driven MCP server: tools come from connector metadata; execution @@ -137,6 +178,14 @@ def __init__( auto_register() self._factory = ConnectorFactory() self._factory.load() + self._upstream_passthrough = _resolve_upstream_passthrough( + self._factory, self._connector_ids + ) + self._upstream_passthrough_scopes = ( + _upstream_passthrough_scopes(self._factory, self._connector_ids) + if self._upstream_passthrough + else () + ) try: from importlib.metadata import version as pkg_version @@ -225,6 +274,8 @@ def _ensure_identity( return authenticate_mcp_request( headers=_http_request_headers.get(), meta=meta, + upstream_passthrough=self._upstream_passthrough, + upstream_granted_scopes=self._upstream_passthrough_scopes, ) def _request_meta_from_context(self) -> Mapping[str, Any] | None: @@ -495,6 +546,9 @@ def _build_streamable_http_app(self, *, session_manager: Any, path: str) -> Any: from starlette.responses import JSONResponse from starlette.routing import Route + upstream_passthrough = self._upstream_passthrough + upstream_granted_scopes = self._upstream_passthrough_scopes + @asynccontextmanager async def lifespan(app: Starlette): async with session_manager.run(): @@ -505,7 +559,11 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override] if request.url.path != path: return await call_next(request) try: - identity = authenticate_mcp_request(headers=request.headers) + identity = authenticate_mcp_request( + headers=request.headers, + upstream_passthrough=upstream_passthrough, + upstream_granted_scopes=upstream_granted_scopes, + ) except McpAuthError as exc: headers: Dict[str, str] = {} if exc.www_authenticate: @@ -522,6 +580,7 @@ async def dispatch(self, request: Request, call_next): # type: ignore[override] return await call_next(request) finally: _streamable_http_identity_ctx.reset(token) + reset_upstream_passthrough_context() # Use a wrapper class to ensure Starlette treats this as an ASGI app # without the automatic redirection logic of Mount(). diff --git a/src/node_wire_google_drive/logic.py b/src/node_wire_google_drive/logic.py index 3b5e49f..e0ba07e 100644 --- a/src/node_wire_google_drive/logic.py +++ b/src/node_wire_google_drive/logic.py @@ -50,27 +50,7 @@ class GoogleDriveConnector(BaseConnector): def build_client(self) -> Any: import asyncio - # get_client_credentials() is async; run it synchronously here since - # build_client() is called from the synchronous get_client() accessor. - try: - loop = asyncio.get_event_loop() - if loop.is_running(): - # In an async context, we can't use run_until_complete. - # Instead, fetch credentials synchronously via the underlying - # ServiceAccountAuthProvider._build_credentials() pattern. - # This code path is reached during connector initialisation - # inside an async frame (e.g. in tests with pytest-asyncio). - import concurrent.futures - - with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: - creds = pool.submit( - lambda: asyncio.run(self._auth_provider.get_client_credentials()) - ).result() - else: - creds = loop.run_until_complete(self._auth_provider.get_client_credentials()) - except RuntimeError: - creds = asyncio.run(self._auth_provider.get_client_credentials()) - + creds = asyncio.run(self._auth_provider.get_client_credentials()) if creds is None: # Fallback for NoAuthProvider or unconfigured provider — # attempt direct secret resolution for backward compatibility. @@ -111,6 +91,13 @@ def build_client(self) -> Any: return build("drive", "v3", credentials=creds) + def get_client(self) -> Any: + if getattr(self._auth_provider, "per_request_credentials", False): + return self.build_client() + if self._client is None: + self._client = self.build_client() + return self._client + def _translate_and_raise_http_error(self, exc: HttpError) -> None: status = exc.resp.status content_str = str(getattr(exc, "content", "") or "") @@ -140,7 +127,18 @@ async def _execute_action_spec( spec = GOOGLE_DRIVE_ACTION_SPECS.get(action_name) if spec is None: raise ValueError(f"No action spec registered for {action_name!r}") - drive = self.get_client() + if getattr(self._auth_provider, "per_request_credentials", False): + creds = await self._auth_provider.get_client_credentials() + if creds is None: + raise GoogleDriveAuthError("Upstream bearer token required") + drive = build("drive", "v3", credentials=creds) + else: + if self._client is None: + creds = await self._auth_provider.get_client_credentials() + if creds is None: + raise GoogleDriveAuthError("Authentication credentials unavailable") + self._client = build("drive", "v3", credentials=creds) + drive = self._client extra = {"trace_id": trace_id, **(log_extra or {})} logger.info("Google Drive %s", action_name, extra=extra) try: diff --git a/src/node_wire_runtime/auth/base.py b/src/node_wire_runtime/auth/base.py index 45eb281..958a488 100644 --- a/src/node_wire_runtime/auth/base.py +++ b/src/node_wire_runtime/auth/base.py @@ -18,9 +18,27 @@ from __future__ import annotations +import contextvars from abc import ABC, abstractmethod from typing import Any, Dict +_upstream_bearer_ctx: contextvars.ContextVar[str | None] = contextvars.ContextVar( + "_upstream_bearer", default=None +) + + +def get_upstream_bearer() -> str | None: + """Bearer token from the current MCP request (upstream OIDC passthrough).""" + return _upstream_bearer_ctx.get() + + +def set_upstream_bearer(token: str | None) -> contextvars.Token: + return _upstream_bearer_ctx.set(token) + + +def reset_upstream_bearer(ctx_token: contextvars.Token) -> None: + _upstream_bearer_ctx.reset(ctx_token) + class AuthProvider(ABC): """ diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index 522f3da..d26a73c 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -462,3 +462,59 @@ def test_factory_builds_service_account_provider() -> None: } provider = factory._build_auth_provider("google_drive", cfg) assert isinstance(provider, ServiceAccountAuthProvider) + + +@pytest.mark.asyncio +async def test_factory_builds_upstream_bearer_provider() -> None: + from bindings.factory import ConnectorFactory + from node_wire_runtime.auth.base import get_upstream_bearer, reset_upstream_bearer, set_upstream_bearer + + sp = _DictSecretProvider({}) + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + + provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + assert getattr(provider, "per_request_credentials", False) is True + + ctx = set_upstream_bearer("google-access-token") + try: + creds = await provider.get_client_credentials() + assert creds is not None + assert creds.token == "google-access-token" + headers = await provider.get_headers() + assert headers["Authorization"] == "Bearer google-access-token" + finally: + reset_upstream_bearer(ctx) + + assert get_upstream_bearer() is None + + +def test_google_drive_auth_provider_env_overrides_yaml( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from bindings.factory import ConnectorFactory + + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + sp = _DictSecretProvider({}) + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + + cfg = { + "auth": { + "provider": "service_account", + "sa_json_secret": "GOOGLE_DRIVE_SA_JSON", + } + } + provider = factory._build_auth_provider("google_drive", cfg) + assert getattr(provider, "per_request_credentials", False) is True + assert not isinstance(provider, ServiceAccountAuthProvider) + + +def test_google_drive_auth_provider_env_invalid_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from bindings.factory import ConnectorFactory, _resolve_google_drive_auth + + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "oauth2") + with pytest.raises(ValueError, match="GOOGLE_DRIVE_AUTH_PROVIDER"): + _resolve_google_drive_auth({"provider": "service_account"}) diff --git a/tests/test_google_drive.py b/tests/test_google_drive.py index 0a8ba8c..81be951 100644 --- a/tests/test_google_drive.py +++ b/tests/test_google_drive.py @@ -19,10 +19,13 @@ ) from node_wire_google_drive.logic import DEFAULT_LIST_FIELDS, GoogleDriveConnector from node_wire_google_drive.schema import ( + FilesListOperation, FilesUploadOperation, GoogleDriveOperationInput, ) from node_wire_runtime import SecretProvider +from node_wire_runtime.auth import ServiceAccountAuthProvider +from node_wire_runtime.auth.base import reset_upstream_bearer, set_upstream_bearer class MockSecretProvider(SecretProvider): @@ -41,7 +44,14 @@ def __init__(self, status: int, *, content: str = "", reason: str = "") -> None: def _connector() -> GoogleDriveConnector: - return GoogleDriveConnector(secret_provider=MockSecretProvider()) + sp = MockSecretProvider() + return GoogleDriveConnector( + secret_provider=sp, + auth_provider=ServiceAccountAuthProvider( + secret_provider=sp, + sa_json_secret="GOOGLE_DRIVE_SA_JSON", + ), + ) def test_files_upload_operation_requires_exactly_one_body_source() -> None: @@ -125,6 +135,53 @@ def test_google_drive_schema_discriminator_validation(): GoogleDriveOperationInput.model_validate({"action": "files.unknown", "file_id": "abc123"}) +@pytest.mark.asyncio +async def test_google_drive_upstream_bearer_uses_request_token() -> None: + from bindings.factory import ConnectorFactory + + sp = MockSecretProvider() + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) + + drive = MagicMock() + files_api = drive.files.return_value + list_call = files_api.list.return_value + list_call.execute.return_value = {"files": []} + + ctx = set_upstream_bearer("google-token-a") + try: + with patch("node_wire_google_drive.logic.build", return_value=drive) as mock_build: + await connector._execute_action_spec( + "files.list", + FilesListOperation(action="files.list", page_size=5), + trace_id="trace-1", + ) + creds = mock_build.call_args.kwargs["credentials"] + assert creds.token == "google-token-a" + finally: + reset_upstream_bearer(ctx) + + +@pytest.mark.asyncio +async def test_google_drive_upstream_bearer_no_token_raises() -> None: + from bindings.factory import ConnectorFactory + + sp = MockSecretProvider() + factory = ConnectorFactory.__new__(ConnectorFactory) + factory._secret_provider = sp + provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) + + with pytest.raises(GoogleDriveAuthError, match="Upstream bearer token required"): + await connector._execute_action_spec( + "files.list", + FilesListOperation(action="files.list", page_size=5), + trace_id="trace-1", + ) + + @pytest.mark.parametrize( ("action", "payload", "status", "expected_exception"), [ diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index 2b1fab9..01c1680 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -15,9 +15,11 @@ McpAuthRequiredError, authenticate_mcp_request, mcp_auth_disabled, + reset_upstream_passthrough_context, ) from bindings.mcp_server.server import McpServer from tests.jwt_test_helpers import mint_test_jwt +from node_wire_runtime.auth.base import get_upstream_bearer @pytest.fixture(autouse=True) @@ -43,6 +45,30 @@ def test_mcp_auth_missing_token_returns_401(monkeypatch: pytest.MonkeyPatch) -> assert exc_info.value.detail == "Authentication required" +def test_mcp_upstream_passthrough_missing_bearer(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + + with pytest.raises(McpAuthRequiredError): + authenticate_mcp_request(upstream_passthrough=True) + + +def test_mcp_upstream_passthrough_accepts_opaque_google_token( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + + identity = authenticate_mcp_request( + headers={"Authorization": "Bearer not-an-nw-api-key"}, + upstream_passthrough=True, + ) + assert identity is not None + assert identity.auth_type == "upstream_bearer" + assert identity.principal == "upstream-bearer" + assert get_upstream_bearer() == "not-an-nw-api-key" + reset_upstream_passthrough_context() + assert get_upstream_bearer() is None + + def test_mcp_auth_invalid_token_returns_403(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") @@ -356,6 +382,80 @@ def test_streamable_http_edge_auth_accepts_valid_token(monkeypatch: pytest.Monke assert response.json()["ok"] is True +def test_streamable_http_upstream_passthrough_accepts_google_bearer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + monkeypatch.setenv("NW_MCP_API_KEY", "unit-test-secret") + + server = McpServer(connector_ids=["google_drive"]) + assert server._upstream_passthrough is True + + app = server._build_streamable_http_app( + session_manager=_FakeStreamableSessionManager(), + path="/mcp", + ) + client = TestClient(app) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"}, + headers={"Authorization": "Bearer google-access-token"}, + ) + + assert response.status_code == 200 + assert response.json()["ok"] is True + + +def test_upstream_passthrough_denied_mode_lists_google_drive_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "deny") + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + + server = McpServer(connector_ids=["google_drive"]) + assert server._upstream_passthrough is True + assert server._upstream_passthrough_scopes + + identity = authenticate_mcp_request( + headers={"Authorization": "Bearer google-access-token"}, + upstream_passthrough=True, + upstream_granted_scopes=server._upstream_passthrough_scopes, + ) + assert identity is not None + + names = {t["name"] for t in server.list_tools(identity=identity)} + assert "google_drive.files.list" in names + assert "google_drive.files.upload" in names + reset_upstream_passthrough_context() + + +def test_streamable_http_upstream_passthrough_denied_lists_tools( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "deny") + monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") + + server = McpServer(connector_ids=["google_drive"]) + assert server._upstream_passthrough_scopes + + app = server._build_streamable_http_app( + session_manager=_FakeStreamableSessionManager(), + path="/mcp", + ) + client = TestClient(app) + response = client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": "1", "method": "tools/list"}, + headers={"Authorization": "Bearer google-access-token"}, + ) + + assert response.status_code == 200 + assert response.json()["ok"] is True + + @pytest.mark.asyncio async def test_streamable_http_identity_context_is_used_by_mcp_server( monkeypatch: pytest.MonkeyPatch, From 21d6dcb4e1b695c558da27e47445d130407a7b1e Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Tue, 30 Jun 2026 12:50:32 +0530 Subject: [PATCH 2/3] PR related issues Signed-off-by: gokul-aot --- src/bindings/mcp_server/auth.py | 6 ++++-- src/node_wire_google_drive/logic.py | 23 ++++++++++++++++------- tests/conftest.py | 1 + tests/test_auth_providers.py | 2 +- tests/test_mcp_auth.py | 19 +++++++++++++++++++ 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/src/bindings/mcp_server/auth.py b/src/bindings/mcp_server/auth.py index 95a3867..be4d05e 100644 --- a/src/bindings/mcp_server/auth.py +++ b/src/bindings/mcp_server/auth.py @@ -237,12 +237,14 @@ def authenticate_mcp_request( upstream_granted_scopes: tuple[str, ...] = (), ) -> CallerIdentity | None: if upstream_passthrough: - if mcp_auth_disabled(): - return None token = extract_token(headers=headers, meta=meta) if not token: + if mcp_auth_disabled(): + return None raise McpAuthRequiredError() _upstream_reset_ctx.set(set_upstream_bearer(token)) + if mcp_auth_disabled(): + return None # Ponytail: MCP scopes gate tool visibility on this server; the Google OAuth # access token on the request is the upstream authz boundary for Drive API. identity = build_caller_identity( diff --git a/src/node_wire_google_drive/logic.py b/src/node_wire_google_drive/logic.py index e0ba07e..c112673 100644 --- a/src/node_wire_google_drive/logic.py +++ b/src/node_wire_google_drive/logic.py @@ -50,7 +50,21 @@ class GoogleDriveConnector(BaseConnector): def build_client(self) -> Any: import asyncio - creds = asyncio.run(self._auth_provider.get_client_credentials()) + async def _fetch_creds() -> Any: + return await self._auth_provider.get_client_credentials() + + try: + loop = asyncio.get_event_loop() + except RuntimeError: + creds = asyncio.run(_fetch_creds()) + else: + if loop.is_running(): + import concurrent.futures + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + creds = pool.submit(asyncio.run, _fetch_creds()).result() + else: + creds = loop.run_until_complete(_fetch_creds()) if creds is None: # Fallback for NoAuthProvider or unconfigured provider — # attempt direct secret resolution for backward compatibility. @@ -133,12 +147,7 @@ async def _execute_action_spec( raise GoogleDriveAuthError("Upstream bearer token required") drive = build("drive", "v3", credentials=creds) else: - if self._client is None: - creds = await self._auth_provider.get_client_credentials() - if creds is None: - raise GoogleDriveAuthError("Authentication credentials unavailable") - self._client = build("drive", "v3", credentials=creds) - drive = self._client + drive = self.get_client() extra = {"trace_id": trace_id, **(log_extra or {})} logger.info("Google Drive %s", action_name, extra=extra) try: diff --git a/tests/conftest.py b/tests/conftest.py index 51a66d7..9d97f67 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -69,6 +69,7 @@ def _preload_connector_logic_modules() -> None: def _rest_auth_disabled_for_tests(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("NW_REST_AUTH_DISABLED", "true") monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "true") + monkeypatch.delenv("GOOGLE_DRIVE_AUTH_PROVIDER", raising=False) monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "allow") monkeypatch.setenv("NW_JWT_AUDIENCE", "node-wire-test") monkeypatch.setenv("NW_JWT_ISSUER", "node-wire-test-issuer") diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index d26a73c..af101cb 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -513,7 +513,7 @@ def test_google_drive_auth_provider_env_overrides_yaml( def test_google_drive_auth_provider_env_invalid_raises( monkeypatch: pytest.MonkeyPatch, ) -> None: - from bindings.factory import ConnectorFactory, _resolve_google_drive_auth + from bindings.factory import _resolve_google_drive_auth monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "oauth2") with pytest.raises(ValueError, match="GOOGLE_DRIVE_AUTH_PROVIDER"): diff --git a/tests/test_mcp_auth.py b/tests/test_mcp_auth.py index 01c1680..97a7173 100644 --- a/tests/test_mcp_auth.py +++ b/tests/test_mcp_auth.py @@ -47,15 +47,33 @@ def test_mcp_auth_missing_token_returns_401(monkeypatch: pytest.MonkeyPatch) -> def test_mcp_upstream_passthrough_missing_bearer(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) with pytest.raises(McpAuthRequiredError): authenticate_mcp_request(upstream_passthrough=True) +def test_mcp_upstream_passthrough_sets_bearer_when_auth_disabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Local dev: NW_MCP_AUTH_DISABLED must still thread Google token to Drive.""" + monkeypatch.setenv("NW_MCP_AUTH_DISABLED", "true") + + identity = authenticate_mcp_request( + headers={"Authorization": "Bearer google-access-token"}, + upstream_passthrough=True, + ) + assert identity is None + assert get_upstream_bearer() == "google-access-token" + reset_upstream_passthrough_context() + assert get_upstream_bearer() is None + + def test_mcp_upstream_passthrough_accepts_opaque_google_token( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) identity = authenticate_mcp_request( headers={"Authorization": "Bearer not-an-nw-api-key"}, @@ -411,6 +429,7 @@ def test_upstream_passthrough_denied_mode_lists_google_drive_tools( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.delenv("NW_MCP_AUTH_ENABLED", raising=False) + monkeypatch.delenv("NW_MCP_AUTH_DISABLED", raising=False) monkeypatch.setenv("NW_MCP_SCOPE_POLICY_DEFAULT", "deny") monkeypatch.setenv("GOOGLE_DRIVE_AUTH_PROVIDER", "upstream_bearer") From 8c769ddfdfc4c3f8819427ef4bce189a94e3db48 Mon Sep 17 00:00:00 2001 From: gokul-aot Date: Tue, 30 Jun 2026 13:04:50 +0530 Subject: [PATCH 3/3] PR related issues-ruff & lint Signed-off-by: gokul-aot --- src/bindings/mcp_server/server.py | 4 ++-- tests/test_auth_providers.py | 10 ++++++++-- tests/test_google_drive.py | 8 ++++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/bindings/mcp_server/server.py b/src/bindings/mcp_server/server.py index ed1104b..adff955 100644 --- a/src/bindings/mcp_server/server.py +++ b/src/bindings/mcp_server/server.py @@ -13,8 +13,8 @@ from typing import Any, Dict, List, Mapping, Optional, Tuple from bindings.factory import ConnectorFactory -from bindings.mcp_server.auth import ( - McpAuthError, +from bindings.mcp_server.auth import ( + McpAuthError, authenticate_mcp_request, reset_upstream_passthrough_context, log_effective_mcp_auth_state, diff --git a/tests/test_auth_providers.py b/tests/test_auth_providers.py index af101cb..e29300f 100644 --- a/tests/test_auth_providers.py +++ b/tests/test_auth_providers.py @@ -467,13 +467,19 @@ def test_factory_builds_service_account_provider() -> None: @pytest.mark.asyncio async def test_factory_builds_upstream_bearer_provider() -> None: from bindings.factory import ConnectorFactory - from node_wire_runtime.auth.base import get_upstream_bearer, reset_upstream_bearer, set_upstream_bearer + from node_wire_runtime.auth.base import ( + get_upstream_bearer, + reset_upstream_bearer, + set_upstream_bearer, + ) sp = _DictSecretProvider({}) factory = ConnectorFactory.__new__(ConnectorFactory) factory._secret_provider = sp - provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + provider = factory._build_auth_provider( + "google_drive", {"auth": {"provider": "upstream_bearer"}} + ) assert getattr(provider, "per_request_credentials", False) is True ctx = set_upstream_bearer("google-access-token") diff --git a/tests/test_google_drive.py b/tests/test_google_drive.py index 81be951..a135883 100644 --- a/tests/test_google_drive.py +++ b/tests/test_google_drive.py @@ -142,7 +142,9 @@ async def test_google_drive_upstream_bearer_uses_request_token() -> None: sp = MockSecretProvider() factory = ConnectorFactory.__new__(ConnectorFactory) factory._secret_provider = sp - provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + provider = factory._build_auth_provider( + "google_drive", {"auth": {"provider": "upstream_bearer"}} + ) connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) drive = MagicMock() @@ -171,7 +173,9 @@ async def test_google_drive_upstream_bearer_no_token_raises() -> None: sp = MockSecretProvider() factory = ConnectorFactory.__new__(ConnectorFactory) factory._secret_provider = sp - provider = factory._build_auth_provider("google_drive", {"auth": {"provider": "upstream_bearer"}}) + provider = factory._build_auth_provider( + "google_drive", {"auth": {"provider": "upstream_bearer"}} + ) connector = GoogleDriveConnector(secret_provider=sp, auth_provider=provider) with pytest.raises(GoogleDriveAuthError, match="Upstream bearer token required"):