Copilot commented on code in PR #39604: URL: https://github.com/apache/superset/pull/39604#discussion_r3210862950
########## superset/mcp_service/composite_token_verifier.py: ########## @@ -0,0 +1,104 @@ +# 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. + +""" +Composite token verifier for MCP authentication. + +Routes Bearer tokens to the appropriate verifier based on prefix: +- Tokens matching FAB_API_KEY_PREFIXES (e.g. ``sst_``) are passed through + to the Flask layer where ``_resolve_user_from_api_key()`` handles + actual validation via FAB SecurityManager. +- All other tokens are delegated to the wrapped JWT verifier (when one is + configured); when no JWT verifier is configured, non-API-key tokens are + rejected at the transport layer. +""" + +import logging + +from fastmcp.server.auth import AccessToken +from fastmcp.server.auth.providers.jwt import TokenVerifier + +logger = logging.getLogger(__name__) + +# Namespaced claim that flags an AccessToken as an API-key pass-through. +# Namespacing avoids collision with custom claims an external IdP might +# happen to mint on a JWT — a plain ``_api_key_passthrough`` claim could +# be silently misidentified as a Superset API-key request. +API_KEY_PASSTHROUGH_CLAIM = "_superset_mcp_api_key_passthrough" + + +class CompositeTokenVerifier(TokenVerifier): + """Routes Bearer tokens between API key pass-through and JWT verification. + + API key tokens (identified by prefix) are accepted at the transport layer + with a marker claim so that ``_resolve_user_from_jwt_context()`` can + detect them and fall through to ``_resolve_user_from_api_key()`` for + actual validation. + + Args: + jwt_verifier: The wrapped JWT verifier for non-API-key tokens. + When ``None``, only API-key tokens are accepted; all other + Bearer tokens are rejected at the transport layer (used when + ``MCP_AUTH_ENABLED=False`` but ``FAB_API_KEY_ENABLED=True``). + api_key_prefixes: List of prefixes that identify API key tokens + (e.g. ``["sst_"]``). + """ + + def __init__( + self, + jwt_verifier: TokenVerifier | None, + api_key_prefixes: list[str], + ) -> None: + super().__init__( + base_url=getattr(jwt_verifier, "base_url", None), + required_scopes=getattr(jwt_verifier, "required_scopes", None) or [], + ) + self._jwt_verifier = jwt_verifier + self._api_key_prefixes = tuple(api_key_prefixes) + + async def verify_token(self, token: str) -> AccessToken | None: + """Verify a Bearer token. + + If the token starts with an API key prefix, return a pass-through + AccessToken with a ``_api_key_passthrough`` claim. The Flask-layer Review Comment: The verify_token() docstring still says the pass-through AccessToken contains a ``_api_key_passthrough`` claim, but the implementation uses the namespaced `API_KEY_PASSTHROUGH_CLAIM` ("_superset_mcp_api_key_passthrough"). Update the docstring to avoid confusing readers and to match the new collision-safe claim name. ########## tests/unit_tests/mcp_service/test_auth_api_key.py: ########## @@ -158,89 +195,154 @@ def test_g_user_fallback_when_no_jwt_or_api_key(app, mock_user) -> None: assert result.username == "api_key_user" -# -- FAB version without extract_api_key_from_request -- +# -- FAB version without validate_api_key -- @pytest.mark.usefixtures("_enable_api_keys") -def test_fab_without_extract_method_skips_gracefully(app) -> None: - """If FAB SecurityManager lacks extract_api_key_from_request, - API key auth should be skipped with a debug log, not crash.""" +def test_fab_without_validate_method_raises(app: SupersetApp) -> None: + """If FAB SecurityManager lacks validate_api_key, should raise + PermissionError about unavailable validation.""" mock_sm = MagicMock(spec=[]) # empty spec = no attributes - with app.test_request_context(): + with app.app_context(): g.user = None app.appbuilder = MagicMock() app.appbuilder.sm = mock_sm - with pytest.raises(ValueError, match="No authenticated user found"): - get_user_from_request() + with _patch_access_token(_passthrough_access_token("sst_abc123")): + with pytest.raises( + PermissionError, match="API key validation is not available" + ): + get_user_from_request() -# -- FAB version without validate_api_key -- +# -- Relationship reload fallback -- @pytest.mark.usefixtures("_enable_api_keys") -def test_fab_without_validate_method_raises(app) -> None: - """If FAB has extract_api_key_from_request but not validate_api_key, - should raise PermissionError about unavailable validation.""" - mock_sm = MagicMock(spec=["extract_api_key_from_request"]) - mock_sm.extract_api_key_from_request.return_value = "sst_abc123" +def test_relationship_reload_failure_returns_original_user( + app: SupersetApp, mock_user: MagicMock +) -> None: + """If load_user_with_relationships fails, the original user from + validate_api_key should be returned as fallback.""" + mock_sm = MagicMock() + mock_sm.validate_api_key.return_value = mock_user - with app.test_request_context(headers={"Authorization": "Bearer sst_abc123"}): + with app.app_context(): g.user = None app.appbuilder = MagicMock() app.appbuilder.sm = mock_sm - with pytest.raises( - PermissionError, match="API key validation is not available" + with ( + _patch_access_token(_passthrough_access_token("sst_abc123")), + patch( + "superset.mcp_service.auth.load_user_with_relationships", + return_value=None, + ), ): - get_user_from_request() + result = get_user_from_request() + + assert result is mock_user -# -- Relationship reload fallback -- +# -- AccessToken without passthrough claim (plain JWT) -> skip API key auth -- @pytest.mark.usefixtures("_enable_api_keys") -def test_relationship_reload_failure_returns_original_user(app, mock_user) -> None: - """If load_user_with_relationships fails, the original user from - validate_api_key should be returned as fallback.""" +def test_jwt_access_token_skips_api_key_auth(app: SupersetApp) -> None: + """When the AccessToken is a plain JWT (no ``_api_key_passthrough`` claim), + API key auth is skipped — the JWT was already validated by the JWT + verifier and resolved in _resolve_user_from_jwt_context.""" Review Comment: This test docstring refers to the legacy ``_api_key_passthrough`` claim name, but the implementation now uses the namespaced `API_KEY_PASSTHROUGH_CLAIM` ("_superset_mcp_api_key_passthrough"). Update the wording so the test documentation matches the behavior being asserted elsewhere in this file. -- 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] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
