nevzheng commented on code in PR #12531: URL: https://github.com/apache/gravitino/pull/12531#discussion_r3847515088
########## mcp-server/mcp_server/core/oauth.py: ########## @@ -0,0 +1,215 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +"""httpx ``auth=`` hook for MCP → Gravitino OAuth2 client-credentials. + +Uses ``httpx-auth`` for fetch and cache on the existing ``httpx.AsyncClient``. +Credentials go in the form body (``client_secret_post``), matching the +Java/Python Gravitino clients. httpx-auth defaults to HTTP Basic. This class +retries once after Gravitino HTTP 401. +""" + +import asyncio +import base64 +import json +import logging +from collections.abc import AsyncGenerator, Generator +from typing import Optional, Union + +import httpx +from httpx_auth import AuthenticationFailed, OAuth2, OAuth2ClientCredentials + +_LOG = logging.getLogger(__name__) + +# Refresh this many seconds before recorded expiry. httpx-auth default is 30. +DEFAULT_REFRESH_SKEW_SECONDS = 60 + +_TokenTuple = Union[tuple[str, str], tuple[str, str, Union[int, str]]] + + +class RefreshableBearerAuth(OAuth2ClientCredentials): + """httpx-auth client-credentials with form POST and one 401 retry.""" + + requires_request_body = True + requires_response_body = True + + def __init__( + self, + *, + token_endpoint: str, + client_id: str, + client_secret: str, + scope: str = "", + refresh_skew_seconds: int = DEFAULT_REFRESH_SKEW_SECONDS, + client: Optional[httpx.Client] = None, + ): + """Build an ``auth=`` hook for the service hop. + + Args: + token_endpoint: Identity-provider token URL. + client_id: OAuth2 client id. + client_secret: OAuth2 client secret. + scope: Optional OAuth2 scope. + refresh_skew_seconds: httpx-auth ``early_expiry``. + client: Optional sync httpx client used only for token POSTs + (tests inject ``MockTransport`` here). + """ + kwargs = {"early_expiry": float(refresh_skew_seconds)} + if scope: + kwargs["scope"] = scope + if client is not None: + kwargs["client"] = client + super().__init__(token_endpoint, client_id, client_secret, **kwargs) + + def invalidate(self) -> None: + """Drop the cached token so the next call fetches a new one.""" + cache = OAuth2.token_cache + # TokenMemoryCache.clear() wipes every client; only drop ours. + with cache._forbid_concurrent_cache_access: # pylint: disable=protected-access + cache.tokens.pop(self.state, None) + + def request_new_token(self) -> _TokenTuple: + """POST ``client_credentials`` with id/secret in the form body.""" + data = self._token_form_data() + client = self.client or httpx.Client() + self._configure_client(client) + try: + response = client.post(self.token_url, data=data) + self._log_token_http_error(response) + response.raise_for_status() + body = response.json() + finally: + if self.client is None: + client.close() + return self._token_tuple(body) + + async def request_new_token_async(self) -> _TokenTuple: + """POST ``client_credentials`` without blocking the event loop.""" + if self.client is not None: + return await asyncio.to_thread(self.request_new_token) + data = self._token_form_data() + async with httpx.AsyncClient() as client: + client.timeout = self.timeout + response = await client.post(self.token_url, data=data) + self._log_token_http_error(response) + response.raise_for_status() + body = response.json() + return self._token_tuple(body) + + def auth_flow( + self, request: httpx.Request + ) -> Generator[httpx.Request, httpx.Response, None]: + """Attach a cached or freshly fetched Bearer; retry once on HTTP 401.""" + self._apply_token(request) + response = yield request + if response.status_code != 401: + return + self.invalidate() + self._apply_token(request) + yield request + + async def async_auth_flow( + self, request: httpx.Request + ) -> AsyncGenerator[httpx.Request, httpx.Response]: + """Attach a Bearer without a blocking IdP POST on the event loop.""" + if self.requires_request_body: + await request.aread() + await self._apply_token_async(request) + response = yield request + if response.status_code != 401: + return + self.invalidate() + await self._apply_token_async(request) + yield request + + def _configure_client(self, client: httpx.Client) -> None: + """Do not send HTTP Basic; id and secret go in the form body.""" + client.timeout = self.timeout + + def _token_form_data(self) -> dict: + data = dict(self.data) + data["client_id"] = self.client_id + data["client_secret"] = self.client_secret + return data + + def _token_tuple(self, body: dict) -> _TokenTuple: + token = body.get(self.token_field_name) + if not token or not isinstance(token, str): + raise ValueError("OAuth token response missing access_token") + expires_in = body.get("expires_in") + _LOG.info("Fetched OAuth access token") + if expires_in not in (None, ""): + return self.state, token, expires_in + if not self._has_jwt_exp(token): + raise ValueError( + "OAuth token response omitted expires_in and " + "access_token is not a JWT with exp" + ) + return self.state, token + + @staticmethod + def _has_jwt_exp(token: str) -> bool: + """Return True when token is a 3-part JWT whose payload has exp. + + Mirrors DefaultOAuth2TokenProvider._expires_at_millis: opaque or + reference tokens must not be handed to httpx-auth as a 2-tuple, + which splits on '.' and crashes the next tool call. + """ + parts = token.split(".") + if len(parts) != 3: + return False + try: + padded = parts[1] + "=" * (-len(parts[1]) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + except (ValueError, json.JSONDecodeError): + return False + return isinstance(payload.get("exp"), int) Review Comment: Agree. Done — JWT exp is now accepted as int/float/string: we derive expires_in from the payload instead of handing httpx-auth a 2-tuple. Tests: test_jwt_exp_float_accepted, test_jwt_exp_string_accepted. Commit b046946b7. Nevin Sent from my 🤖 (Cursor) ########## mcp-server/mcp_server/main.py: ########## @@ -35,7 +35,12 @@ def do_main(): token=args.token, tls_cert=args.tls_cert, tls_key=args.tls_key, + oauth_token_endpoint=args.oauth_token_endpoint, + oauth_client_id=args.oauth_client_id, + oauth_client_secret=args.oauth_client_secret, + oauth_scope=args.oauth_scope, ) + setting.validate_oauth() Review Comment: Agree. Done — _init_logging() runs before validate_oauth(); partial config logs a clean error and exits 1 instead of a traceback on stdio. --oauth-scope alone is now rejected. Tests: test_partial_oauth_inits_logging_before_exit, test_oauth_scope_without_credentials_is_rejected. Commit b046946b7. Nevin Sent from my 🤖 (Cursor) ########## mcp-server/pyproject.toml: ########## @@ -24,6 +24,8 @@ requires-python = ">=3.10" dependencies = [ # Pin FastMCP so breaking API changes are handled explicitly during dependency upgrades. "fastmcp==3.4.5", + # httpx.Auth plugin for hop-2 client_credentials fetch/cache. + "httpx-auth>=0.22", Review Comment: Agree. Done — pinned httpx-auth>=0.22,<0.24 in pyproject.toml (and uv.lock). Commit b046946b7. Nevin Sent from my 🤖 (Cursor) ########## docs/gravitino-mcp-server.md: ########## @@ -175,11 +187,31 @@ export GRAVITINO_TOKEN=<your-token> uv run mcp_server --metalake test --gravitino-uri http://127.0.0.1:8090 ``` -In `stdio` mode this token is used for every request. In HTTP mode it is only the fallback, used when an incoming request does not carry its own `Authorization` header. +In `stdio` mode this token is used for every request. In HTTP mode it is only the fallback, used when an incoming request does not carry its own `Authorization` header. If both `--token` and OAuth client-credentials are set, `--token` wins. + +### OAuth client credentials (service identity) + +When Gravitino uses `gravitino.authenticators = oauth`, a pasted Bearer access token in `--token` expires and is not refreshed. For the **service** identity (Cursor stdio, or HTTP when the caller sends no `Authorization` header), configure MCP as an OAuth client of the same identity provider Gravitino trusts. + +Set `--oauth-token-endpoint`, `--oauth-client-id`, and `--oauth-client-secret` together, plus optional `--oauth-scope` (or the matching `GRAVITINO_OAUTH_*` environment variables). MCP requests an access token with the `client_credentials` grant, caches it, refreshes before expiry, and retries once on HTTP 401. + +In Cursor, put the `GRAVITINO_OAUTH_*` values in the `env` block of `~/.cursor/mcp.json` (see [Usage](#usage)). `--token` / `GRAVITINO_TOKEN` overrides OAuth client-credentials and stays static (no refresh). An incoming HTTP `Authorization` header is forwarded as-is and is not refreshed by MCP. + +Gravitino maps the JWT to a metalake principal from claims configured in [`gravitino.authenticator.oauth.principalFields`](./security/how-to-authenticate.md#server-configuration) (often `sub`); that principal may differ from `--oauth-client-id`. It must exist as a metalake user with the needed grants, or tool calls fail with 403. + +```shell +uv run mcp_server --metalake test --gravitino-uri http://127.0.0.1:8090 \ + --oauth-token-endpoint https://idp.example/realms/gravitino/protocol/openid-connect/token \ + --oauth-client-id mcp-service \ + --oauth-client-secret <secret> \ Review Comment: Agree. Done — OAuth client-credentials example now leads with GRAVITINO_OAUTH_* env vars and warns against --oauth-client-secret on the CLI (ps/history). Commit b046946b7. Nevin Sent from my 🤖 (Cursor) -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
