This is an automated email from the ASF dual-hosted git repository.

aminghadersohi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new e165c386fbf fix(mcp): recognise guest tokens on FastMCP 4 (#45639)
e165c386fbf is described below

commit e165c386fbf66a77390c39f804a7d7ab888ca59a
Author: Amin Ghadersohi <[email protected]>
AuthorDate: Fri Oct 23 21:45:06 2026 -0400

    fix(mcp): recognise guest tokens on FastMCP 4 (#45639)
---
 UPDATING.md                                        |  13 +
 docs/admin_docs/configuration/mcp-server.mdx       |   3 +-
 superset/mcp_service/auth.py                       |  85 ++-
 superset/mcp_service/composite_token_verifier.py   |  51 +-
 superset/mcp_service/guest_token_verifier.py       |   6 +-
 superset/mcp_service/mcp_config.py                 |  10 +-
 superset/mcp_service/server.py                     |  11 +-
 tests/unit_tests/mcp_service/test_auth_api_key.py  |  10 +-
 .../mcp_service/test_guest_token_auth.py           |   7 +
 .../mcp_service/test_multi_auth_client_id.py       | 678 +++++++++++++++++++++
 10 files changed, 830 insertions(+), 44 deletions(-)

diff --git a/UPDATING.md b/UPDATING.md
index 093b38a3032..c6bce7af5e7 100644
--- a/UPDATING.md
+++ b/UPDATING.md
@@ -24,6 +24,19 @@ assists people when migrating to a new version.
 
 ## Next
 
+- **MCP authentication hooks and disabled API keys:** Custom 
`MCP_USER_RESOLVER`
+  functions that read `access_token.client_id` must use
+  `get_verifier_client_id(access_token)` from
+  `superset.mcp_service.composite_token_verifier` instead. FastMCP 4's 
`MultiAuth`
+  qualifies `client_id`; the helper returns the originating verifier's ID on
+  both FastMCP 3 and 4. This ID routes authentication; it is not proof that the
+  bearer is valid. When API keys are disabled (`MCP_API_KEY_ENABLED=False`, or
+  `FAB_API_KEY_ENABLED=False` without an MCP override), an explicit API-key
+  pass-through token raises `PermissionError` instead of falling through to
+  another identity. `tools/list` returns an empty list for that client, not
+  public tools. Remove the API-key credential to use another authentication
+  method, or explicitly enable API-key authentication.
+
 - **Large nullable-integer dimensions stay exact in chart data.** In a plain 
chart
   query (no post-processing or time offsets, and not a report's 
client-processed
   result), a dimension column of nullable integers that holds a value beyond
diff --git a/docs/admin_docs/configuration/mcp-server.mdx 
b/docs/admin_docs/configuration/mcp-server.mdx
index e7d48609feb..57f0e248615 100644
--- a/docs/admin_docs/configuration/mcp-server.mdx
+++ b/docs/admin_docs/configuration/mcp-server.mdx
@@ -661,6 +661,7 @@ curl -X POST http://localhost:5008/mcp \
 
 - A dedicated guest-token verifier validates the token against the same 
`GUEST_TOKEN_JWT_SECRET` / `GUEST_TOKEN_JWT_ALGO` / `GUEST_TOKEN_JWT_AUDIENCE` 
config used by embedded dashboards, replays the embedded structural checks, and 
enforces revocation (global version bumps and per-dashboard 
`guest_token_revoked_before` cutoffs). It runs *before* the JWT verifier 
described above, since guest tokens are signed with a different key/algorithm 
and would otherwise be rejected at the transport.
 - A verified guest resolves to a Superset guest user as the highest-priority 
identity, so it's never downgraded to API-key / `MCP_DEV_USERNAME` / dev-mode 
resolution. Data access is scoped by the same checks (dataset allowlist, 
dashboard access, row-level security) that apply to embedded dashboard views.
+- Guest and API-key identity resolution supports FastMCP 3 and FastMCP 4, 
including verifier chains wrapped in `MultiAuth`. Guest resolution revalidates 
the bearer and core revocation checks on every identity resolution (including 
tool discovery), not just transport verification. These checks are not cached 
across resolutions: each dashboard resource can require a metadata lookup, but 
a previous successful resolution cannot mask subsequent expiry or revocation; 
API-key resolution trusts  [...]
 - Guests are restricted to a default-deny allow-list, 
`MCP_GUEST_ALLOWED_TOOLS`, regardless of `MCP_RBAC_ENABLED`. Sensitive 
enumeration tools like `find_users` and `get_instance_info` are denied simply 
by being absent from the default list.
 - Setting `MCP_AUTH_FACTORY` bypasses this whole path: a configured factory is 
tried first, and the default factory that wires up the guest-token verifier is 
never reached. If you rely on a custom auth factory (e.g. your own OIDC 
provider) alongside guest auth, that factory must verify guest tokens itself -- 
otherwise they're rejected regardless of `MCP_EMBEDDED_GUEST_AUTH_ENABLED`.
 
@@ -980,7 +981,7 @@ All MCP settings go in `superset_config.py`. Defaults are 
defined in `superset/m
 | `MCP_REQUIRED_SCOPES`  | `[]`      | Required JWT scopes                     
                                                                                
                                                                                
                         |
 | `MCP_JWT_DEBUG_ERRORS` | `False`   | Log detailed JWT errors server-side 
(never exposed in HTTP responses per RFC 6750)                                  
                                                                                
                             |
 | `MCP_AUTH_FACTORY`     | `None`    | Custom auth provider factory 
`(flask_app) -> auth_provider`. Takes precedence over built-in JWT              
                                                                                
                                    |
-| `MCP_USER_RESOLVER`    | `None`    | Custom function `(app, access_token) -> 
username` to extract a Superset username from a validated JWT token. When 
`None`, the default resolver checks `preferred_username`, `username`, `email`, 
and `sub` claims in that order. |
+| `MCP_USER_RESOLVER`    | `None`    | Custom function `(app, access_token) -> 
username` to extract a Superset username from a validated JWT token. Use 
`get_verifier_client_id(access_token)` from 
`superset.mcp_service.composite_token_verifier` instead of reading 
`access_token.client_id`: FastMCP 4 `MultiAuth` qualifies that field. The 
helper supports FastMCP 3 and 4 and identifies the originating verifier, not 
proof of bearer validity. When `None`, the default resolver checks `preferred_ 
[...]
 | `MCP_EMBEDDED_GUEST_AUTH_ENABLED` | `False` | Accept embedded [guest 
tokens](#embedded-guest-authentication) as Bearer auth. Also requires the 
`EMBEDDED_SUPERSET` feature flag. |
 | `MCP_GUEST_ALLOWED_TOOLS` | see [default 
list](#embedded-guest-authentication) | The only tool names callable by 
embedded guests (default-deny), regardless of `MCP_RBAC_ENABLED`. |
 
diff --git a/superset/mcp_service/auth.py b/superset/mcp_service/auth.py
index 746e9044cb4..3227b1eb9e2 100644
--- a/superset/mcp_service/auth.py
+++ b/superset/mcp_service/auth.py
@@ -64,7 +64,8 @@ from flask_appbuilder.security.sqla.models import User
 from superset import is_feature_enabled, security_manager
 from superset.mcp_service.composite_token_verifier import (
     API_KEY_PASSTHROUGH_CLAIM,
-    API_KEY_VALIDATED_USERNAME_CLAIM,
+    get_verifier_client_id,
+    TransportValidatedAPIKeyToken,
 )
 from superset.mcp_service.guest_token_verifier import GUEST_TOKEN_CLAIM
 from superset.mcp_service.mcp_config import (
@@ -595,6 +596,45 @@ def load_user_with_relationships(
     return security_manager.find_user_with_relationships(username=username, 
email=email)
 
 
+def _reverify_guest_bearer(access_token: Any) -> dict[str, Any]:
+    """Re-verify a guest-marked token's raw bearer as a Superset guest token.
+
+    The guest marker and ``client_id == "guest"`` identify a token admitted by
+    ``GuestTokenVerifier``, but neither is bound to the bearer: a token source
+    that does not strip the marker (e.g. a verifier wrapped next to the
+    ``CompositeTokenVerifier`` in a FastMCP ``MultiAuth``) can pass both 
through
+    from an external IdP JWT. Decoding the bearer with 
``GUEST_TOKEN_JWT_SECRET``
+    binds the guest identity to a token only Superset can mint, and the decoded
+    claims (not the ``AccessToken`` copy) build the ``GuestUser``.
+
+    Raises:
+        ValueError: If the bearer is not a valid guest token (fail closed).
+    """
+    raw_token = getattr(access_token, "token", None)
+    parsed: Any = None
+    try:
+        if isinstance(raw_token, str) and raw_token:
+            parsed = security_manager.parse_jwt_guest_token(raw_token)
+        if (
+            not isinstance(parsed, dict)
+            or parsed.get("type") != "guest"
+            or parsed.get("user") is None
+            or parsed.get("resources") is None
+            or parsed.get("rls_rules") is None
+            or not parsed.get("exp")
+            or security_manager._is_guest_token_revoked(parsed)  # noqa: SLF001
+        ):
+            parsed = None
+    except Exception:  # noqa: BLE001 — verification or revocation backend 
failure
+        # Discovery must treat backend failures as rejected credentials, not
+        # unexpected setup errors that can expose public tools.
+        parsed = None
+    if parsed is None:
+        logger.warning("Guest-marked token is not a valid guest token; 
rejecting")
+        raise ValueError("Guest-marked token is not a valid guest token")
+    return parsed
+
+
 def _resolve_user_from_jwt_context(app: Any) -> MCPUser | None:  # noqa: C901
     """
     Resolve the current user from the MCP SDK's per-request JWT context.
@@ -609,13 +649,15 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | 
None:  # noqa: C901
     Returns:
         User object with relationships loaded, or None if no JWT context
         (i.e. no token present — caller should fall through to next source).
-        For a verified embedded guest token (``client_id == "guest"``) returns
-        the corresponding ``GuestUser`` built from the token's resources/RLS.
+        For a verified embedded guest token (verifier ``client_id == "guest"``,
+        see ``get_verifier_client_id``) returns the corresponding ``GuestUser``
+        built from the re-verified bearer's resources/RLS.
 
     Raises:
         ValueError: If JWT resolves a username that doesn't exist in the DB,
             or a guest-marked token is presented while guest auth is disabled
-            (fail closed — do NOT fall through to weaker auth sources).
+            or its bearer is not a valid guest token (fail closed — do NOT fall
+            through to weaker auth sources).
         MCPAuthConfigError: If more than one JWT issuer is trusted
             (``MCP_JWT_ISSUER`` is a list/tuple/set) and no issuer-aware
             ``MCP_USER_RESOLVER`` is configured (fail closed — see
@@ -642,12 +684,14 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | 
None:  # noqa: C901
     # Embedded guest token (already admitted by the GuestTokenVerifier): 
resolve
     # as the highest-priority identity so a valid guest is never downgraded.
     # Anti-forgery: only the GuestTokenVerifier sets the marker (the composite
-    # verifier strips it from JWT tokens) and this branch requires guest auth
-    # enabled, so a crafted IdP JWT with the marker can't pose as a guest.
+    # verifier strips it from JWT tokens), this branch requires guest auth
+    # enabled, and the raw bearer is re-verified as a guest token below, so a
+    # crafted IdP JWT with the marker can't pose as a guest even when it 
reaches
+    # here through a token source that does not strip the marker.
     if (
         isinstance(claims, dict)
         and claims.get(GUEST_TOKEN_CLAIM)
-        and getattr(access_token, "client_id", None) == "guest"
+        and get_verifier_client_id(access_token) == "guest"
     ):
         if not (
             is_feature_enabled("EMBEDDED_SUPERSET")
@@ -665,17 +709,20 @@ def _resolve_user_from_jwt_context(app: Any) -> MCPUser | 
None:  # noqa: C901
             raise ValueError(
                 "Guest-marked token presented but embedded guest auth is not 
enabled"
             )
+        guest_claims = _reverify_guest_bearer(access_token)
         logger.debug("Resolving MCP request as embedded guest user")
-        # Drop the internal marker so it does not leak into 
GuestUser.guest_token.
-        guest_claims: dict[str, Any] = {
-            k: v for k, v in claims.items() if k != GUEST_TOKEN_CLAIM
-        }
         return security_manager.get_guest_user_from_token(
             cast("GuestToken", guest_claims)
         )
 
     if isinstance(claims, dict) and claims.get(API_KEY_PASSTHROUGH_CLAIM):
-        if getattr(access_token, "client_id", None) == "api_key":
+        if get_verifier_client_id(access_token) == "api_key":
+            if not get_mcp_api_key_enabled(app):
+                # An explicit authentication attempt must not fall through to
+                # a development or middleware identity when keys are disabled.
+                raise PermissionError(
+                    "Authentication required. No valid credentials provided."
+                )
             logger.debug(
                 "API key pass-through token detected, deferring to API key 
auth"
             )
@@ -823,17 +870,17 @@ def _resolve_user_from_api_key(app: Any) -> User | None:
         return None
     # Defense-in-depth: require client_id=="api_key" (set by 
CompositeTokenVerifier)
     # to guard against rogue external IdP JWTs that include the passthrough 
claim.
-    if getattr(access_token, "client_id", None) != "api_key":
+    if get_verifier_client_id(access_token) != "api_key":
         return None
 
-    # Fast path: transport layer already validated the key and stored the
-    # username in the claim — skip the second DB call.
-    if validated_username := claims.get(API_KEY_VALIDATED_USERNAME_CLAIM):
+    # Fast path: only our FAB-validating verifier can produce this token type.
+    # A username claim and client_id alone can both come from an external IdP.
+    if isinstance(access_token, TransportValidatedAPIKeyToken):
         _redact_access_token(access_token)
-        return _load_api_key_user_by_username(validated_username)
+        return _load_api_key_user_by_username(access_token.validated_username)
 
-    # Fallback: no transport-level validation (app=None in 
CompositeTokenVerifier).
-    # Validate the raw token against FAB here instead.
+    # Fallback: prefix-only acceptance, a deserialized token, or an external
+    # source's claims. Validate the raw token against FAB here instead.
     api_key_string = getattr(access_token, "token", None)
     _redact_access_token(access_token)
     return _validate_api_key_fallback(app, api_key_string)
diff --git a/superset/mcp_service/composite_token_verifier.py 
b/superset/mcp_service/composite_token_verifier.py
index ad62130eded..4c1558e29fe 100644
--- a/superset/mcp_service/composite_token_verifier.py
+++ b/superset/mcp_service/composite_token_verifier.py
@@ -43,12 +43,38 @@ logger = logging.getLogger(__name__)
 # be silently misidentified as a Superset API-key request.
 API_KEY_PASSTHROUGH_CLAIM = "_superset_mcp_api_key_passthrough"
 
-# Claim that carries the FAB-validated username after transport-layer
-# API key validation. When present, ``_resolve_user_from_api_key`` skips
-# the second DB call and loads the user directly by username.
+# Compatibility claim carrying the username after transport-layer validation.
+# Resolution trusts TransportValidatedAPIKeyToken, never this claim alone.
 API_KEY_VALIDATED_USERNAME_CLAIM = "_superset_mcp_validated_username"
 
 
+class TransportValidatedAPIKeyToken(AccessToken):
+    """Carry FAB validation evidence that an external JWT cannot manufacture.
+
+    MultiAuth's model_copy preserves this type. If a transport serializes it
+    into a plain AccessToken, resolution falls back to FAB validation instead
+    of trusting the serialized claims.
+    """
+
+    validated_username: str
+
+
+def get_verifier_client_id(access_token: Any) -> str | None:
+    """Return the ``client_id`` set by the verifier that admitted the token.
+
+    FastMCP 4's ``MultiAuth`` replaces ``AccessToken.client_id`` with a
+    source-qualified JSON string and keeps the verifier's own value in
+    ``original_client_id``. Comparisons against the identifiers this module and
+    ``GuestTokenVerifier`` assign (``"api_key"``, ``"guest"``) must use the
+    verifier's value, which is ``client_id`` itself on FastMCP 3 or when no
+    ``MultiAuth`` wraps the verifier.
+    """
+    original = getattr(access_token, "original_client_id", None)
+    if isinstance(original, str):
+        return original
+    return getattr(access_token, "client_id", None)
+
+
 class CompositeTokenVerifier(TokenVerifier):
     """Routes Bearer tokens between API key validation and JWT verification.
 
@@ -205,9 +231,10 @@ class CompositeTokenVerifier(TokenVerifier):
                     )
                     return None
                 username, key_scopes = result
-                return AccessToken(
+                return TransportValidatedAPIKeyToken(
                     token=token,
                     client_id="api_key",
+                    validated_username=username,
                     # Preserve the key's own scopes exactly. An empty list
                     # means "no scopes advertised" and therefore retains the
                     # RBAC-only behavior for existing unscoped API keys.
@@ -248,16 +275,22 @@ class CompositeTokenVerifier(TokenVerifier):
             return None
 
         jwt_access_token = await self._jwt_verifier.verify_token(token)
-        # Anti-forgery: only the GuestTokenVerifier may set the guest marker.
-        # Strip it from JWT-verified tokens so a crafted IdP JWT can't pose as
-        # a verified guest.
+        # Internal identity-routing claims are not evidence of validation
+        # when supplied by an IdP. Preserve the JWT's own user identity.
         if jwt_access_token is not None:
             jwt_claims = getattr(jwt_access_token, "claims", None)
-            if isinstance(jwt_claims, dict) and GUEST_TOKEN_CLAIM in 
jwt_claims:
+            internal_claims = {
+                GUEST_TOKEN_CLAIM,
+                API_KEY_PASSTHROUGH_CLAIM,
+                API_KEY_VALIDATED_USERNAME_CLAIM,
+            }
+            if isinstance(jwt_claims, dict) and internal_claims.intersection(
+                jwt_claims
+            ):
                 # Rebuild, not mutate: claims may be immutable/copied,
                 # so pop() can no-op.
                 stripped = {
-                    k: v for k, v in jwt_claims.items() if k != 
GUEST_TOKEN_CLAIM
+                    k: v for k, v in jwt_claims.items() if k not in 
internal_claims
                 }
                 jwt_access_token = jwt_access_token.model_copy(
                     update={"claims": stripped}
diff --git a/superset/mcp_service/guest_token_verifier.py 
b/superset/mcp_service/guest_token_verifier.py
index 8b4fb022285..434fb03b1c4 100644
--- a/superset/mcp_service/guest_token_verifier.py
+++ b/superset/mcp_service/guest_token_verifier.py
@@ -45,8 +45,10 @@ from fastmcp.server.auth.providers.jwt import TokenVerifier
 
 logger = logging.getLogger(__name__)
 
-# Namespaced claim flagging an AccessToken as a verified guest token. The 
paired
-# ``client_id == "guest"`` check keeps an external IdP JWT from posing as a 
guest.
+# Namespaced claim flagging an AccessToken as a verified guest token. At
+# resolution it is honored only with the verifier's ``client_id == "guest"`` 
and
+# a bearer that re-verifies as a guest token, so an external IdP JWT carrying 
the
+# claim cannot pose as a guest.
 GUEST_TOKEN_CLAIM: str = "_superset_mcp_guest_token"  # noqa: S105
 
 
diff --git a/superset/mcp_service/mcp_config.py 
b/superset/mcp_service/mcp_config.py
index 466ab282ee2..08ca38d98bf 100644
--- a/superset/mcp_service/mcp_config.py
+++ b/superset/mcp_service/mcp_config.py
@@ -25,7 +25,10 @@ from fastmcp.server.auth.providers.jwt import JWTVerifier
 from flask import Flask
 
 from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET
-from superset.mcp_service.composite_token_verifier import 
CompositeTokenVerifier
+from superset.mcp_service.composite_token_verifier import (
+    CompositeTokenVerifier,
+    get_verifier_client_id,
+)
 from superset.mcp_service.constants import (
     DEFAULT_MAX_LIST_ITEMS,
     DEFAULT_MAX_PNG_RESPONSE_BYTES,
@@ -954,8 +957,9 @@ def default_user_resolver(app: Any, access_token: Any) -> 
str | None:
     # Legacy attribute access for backward compatibility
     if hasattr(access_token, "subject") and access_token.subject:
         return access_token.subject
-    if hasattr(access_token, "client_id") and access_token.client_id:
-        return access_token.client_id
+    # The verifier's own client_id, not FastMCP MultiAuth's qualified form.
+    if client_id := get_verifier_client_id(access_token):
+        return client_id
     if hasattr(access_token, "payload") and isinstance(access_token.payload, 
dict):
         return (
             access_token.payload.get("sub")
diff --git a/superset/mcp_service/server.py b/superset/mcp_service/server.py
index eb6019ac508..243c0e2708e 100644
--- a/superset/mcp_service/server.py
+++ b/superset/mcp_service/server.py
@@ -456,21 +456,22 @@ def _tool_allowed_for_current_user(tool: Any) -> bool:
             _get_app_context_manager,
             get_user_from_request,
             is_tool_visible_to_current_user,
+            MCPNoAuthSourceError,
         )
 
         def _check() -> bool:
             if not getattr(g, "user", None):
                 try:
                     g.user = get_user_from_request()
-                except PermissionError:
-                    # Invalid credentials (bad API key) → deny all, matching
-                    # RBACToolVisibilityMiddleware's fail-closed behaviour.
-                    return False
-                except ValueError:
+                except MCPNoAuthSourceError:
                     # No auth source configured → only pass public tools
                     # (those with no class-level permission requirement).
                     func = getattr(tool, "fn", tool)
                     return not getattr(func, "_class_permission_name", None)
+                except (PermissionError, ValueError):
+                    # Rejected API-key, JWT, or guest credentials deny all 
tools,
+                    # matching RBACToolVisibilityMiddleware's fail-closed path.
+                    return False
             return is_tool_visible_to_current_user(tool)
 
         if has_app_context():
diff --git a/tests/unit_tests/mcp_service/test_auth_api_key.py 
b/tests/unit_tests/mcp_service/test_auth_api_key.py
index 3b88b6b7424..bbd838608b7 100644
--- a/tests/unit_tests/mcp_service/test_auth_api_key.py
+++ b/tests/unit_tests/mcp_service/test_auth_api_key.py
@@ -167,18 +167,17 @@ def test_invalid_api_key_raises(app: SupersetApp) -> None:
         app.config.pop("MCP_DEV_USERNAME", None)
 
 
-# -- API key disabled -> falls through to next auth method --
+# -- API key disabled -> rejects an explicit authentication attempt --
 
 
 @pytest.mark.usefixtures("_disable_api_keys")
-def test_api_key_disabled_skips_auth(app: SupersetApp) -> None:
-    """When FAB_API_KEY_ENABLED is False, API key auth is skipped entirely
-    even if an AccessToken is present."""
+def test_api_key_disabled_rejects_explicit_auth(app: SupersetApp) -> None:
+    """Disabled API-key authentication cannot fall through to weaker 
sources."""
     mock_sm = MagicMock()
 
     with _mock_sm_ctx(app, mock_sm):
         with _patch_access_token(_passthrough_access_token("sst_abc123")):
-            with pytest.raises(MCPNoAuthSourceError):
+            with pytest.raises(PermissionError, match="Authentication 
required"):
                 get_user_from_request()
 
     mock_sm.validate_api_key.assert_not_called()
@@ -338,6 +337,7 @@ def test_jwt_access_token_skips_api_key_auth(app: 
SupersetApp) -> None:
 # -- API key pass-through detection in JWT context resolver --
 
 
[email protected]("_enable_api_keys")
 def test_jwt_context_with_api_key_passthrough_returns_none(app: SupersetApp) 
-> None:
     """When CompositeTokenVerifier passes through an API key token,
     _resolve_user_from_jwt_context should detect the namespaced
diff --git a/tests/unit_tests/mcp_service/test_guest_token_auth.py 
b/tests/unit_tests/mcp_service/test_guest_token_auth.py
index 09e5fca9830..63b00228c2b 100644
--- a/tests/unit_tests/mcp_service/test_guest_token_auth.py
+++ b/tests/unit_tests/mcp_service/test_guest_token_auth.py
@@ -254,6 +254,7 @@ async def 
test_composite_falls_through_to_jwt_for_non_guest() -> None:
 
 def test_resolve_builds_guest_user_from_token(app: SupersetApp) -> None:
     token = MagicMock()
+    token.token = "raw-guest-token"  # noqa: S105 — test fixture
     token.claims = {GUEST_TOKEN_CLAIM: True, **_parsed_guest_claims()}
     token.client_id = "guest"
     fake_guest = MagicMock()
@@ -267,9 +268,15 @@ def test_resolve_builds_guest_user_from_token(app: 
SupersetApp) -> None:
             patch("superset.mcp_service.auth.security_manager") as mock_sm,
         ):
             mock_sm.get_guest_user_from_token = 
MagicMock(return_value=fake_guest)
+            mock_sm.parse_jwt_guest_token = MagicMock(
+                return_value=_parsed_guest_claims()
+            )
+            mock_sm._is_guest_token_revoked = MagicMock(return_value=False)
             result = _resolve_user_from_jwt_context(app)
 
     assert result is fake_guest
+    # The raw bearer is re-verified as a guest token before resolution.
+    mock_sm.parse_jwt_guest_token.assert_called_once_with(token.token)
     # The internal marker must not leak into the GuestUser's token dict.
     passed_token = mock_sm.get_guest_user_from_token.call_args.args[0]
     assert GUEST_TOKEN_CLAIM not in passed_token
diff --git a/tests/unit_tests/mcp_service/test_multi_auth_client_id.py 
b/tests/unit_tests/mcp_service/test_multi_auth_client_id.py
new file mode 100644
index 00000000000..f15c41bae97
--- /dev/null
+++ b/tests/unit_tests/mcp_service/test_multi_auth_client_id.py
@@ -0,0 +1,678 @@
+# 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.
+
+"""Identity resolution for tokens wrapped by FastMCP's ``MultiAuth``.
+
+FastMCP 4's ``MultiAuth`` rewrites ``AccessToken.client_id`` into a
+source-qualified JSON string and keeps the verifier's own value in
+``original_client_id``. These tests drive real verifier chains
+(``GuestTokenVerifier`` / ``CompositeTokenVerifier`` inside ``MultiAuth``) and
+check that guest and API-key tokens still resolve to the right principal, and
+that a forged guest-marked token still cannot pose as a guest.
+"""
+
+import time
+from collections.abc import Iterator
+from types import SimpleNamespace
+from typing import Any
+from unittest.mock import MagicMock, patch
+
+import jwt
+import pytest
+from fastmcp.server.auth import AccessToken, MultiAuth
+from fastmcp.server.auth.providers.jwt import TokenVerifier
+from sqlalchemy.exc import SQLAlchemyError
+
+from superset import security_manager
+from superset.app import SupersetApp
+from superset.mcp_service.auth import (
+    _resolve_user_from_api_key,
+    _resolve_user_from_jwt_context,
+)
+from superset.mcp_service.composite_token_verifier import (
+    API_KEY_PASSTHROUGH_CLAIM,
+    API_KEY_VALIDATED_USERNAME_CLAIM,
+    CompositeTokenVerifier,
+)
+from superset.mcp_service.guest_token_verifier import (
+    GUEST_TOKEN_CLAIM,
+    GuestTokenVerifier,
+)
+from superset.mcp_service.mcp_config import default_user_resolver
+from superset.mcp_service.middleware import RBACToolVisibilityMiddleware
+
+GUEST_SECRET = "a-strong-guest-token-secret-for-tests"  # noqa: S105
+
+# FastMCP 4's MultiAuth qualifies client_id; FastMCP 3's passes it through.
+MULTI_AUTH_QUALIFIES_CLIENT_ID = "original_client_id" in 
AccessToken.model_fields
+
+
[email protected](autouse=True)
+def isolate_identity_config(app: SupersetApp) -> Iterator[None]:
+    """Restore the module-scoped app's auth configuration after each test."""
+    with patch.dict(app.config):
+        yield
+
+
+class _StubVerifier(TokenVerifier):
+    """A token source that returns a fixed AccessToken for any bearer."""
+
+    def __init__(self, result: AccessToken | None) -> None:
+        super().__init__(base_url=None, required_scopes=[])
+        self._result = result
+
+    async def verify_token(self, token: str) -> AccessToken | None:
+        return self._result
+
+
+def _guest_claims() -> dict[str, Any]:
+    return {
+        "user": {"username": "embed-guest"},
+        "resources": [{"type": "dashboard", "id": "abc-uuid"}],
+        "rls_rules": [],
+        "iat": int(time.time()),
+        "exp": int(time.time()) + 300,
+        "aud": "superset-test",
+        "type": "guest",
+    }
+
+
+def _mint_guest_token(claims: dict[str, Any], secret: str = GUEST_SECRET) -> 
str:
+    return jwt.encode(claims, secret, algorithm="HS256")
+
+
[email protected]
+def guest_app(app: SupersetApp) -> SupersetApp:
+    """Enable embedded guest auth over MCP with a known guest-token secret."""
+    app.config.update(
+        {
+            "MCP_EMBEDDED_GUEST_AUTH_ENABLED": True,
+            "GUEST_TOKEN_JWT_SECRET": GUEST_SECRET,
+            "GUEST_TOKEN_JWT_ALGO": "HS256",
+            "GUEST_TOKEN_JWT_AUDIENCE": "superset-test",
+        }
+    )
+    return app
+
+
+async def _verify_through_chain(
+    app: SupersetApp, raw_token: str, *, multi_auth: bool
+) -> AccessToken | None:
+    """Run ``raw_token`` through the default guest chain, optionally in 
MultiAuth."""
+    composite = CompositeTokenVerifier(
+        jwt_verifier=None,
+        api_key_prefixes=[],
+        app=app,
+        guest_verifier=GuestTokenVerifier(app=app),
+    )
+    verifier: Any = MultiAuth(verifiers=[composite]) if multi_auth else 
composite
+    sm = app.appbuilder.sm
+    with (
+        patch("superset.is_feature_enabled", return_value=True),
+        patch.object(sm, "_is_guest_token_revoked", return_value=False),
+        patch.object(sm, "find_role", return_value=object()),
+    ):
+        return await verifier.verify_token(raw_token)
+
+
+def _resolve_guest(app: SupersetApp, access_token: Any) -> tuple[Any, 
MagicMock]:
+    """Resolve ``access_token`` via the JWT-context path, guest flags on."""
+    fake_guest = MagicMock(name="GuestUser")
+    with (
+        patch(
+            "fastmcp.server.dependencies.get_access_token", 
return_value=access_token
+        ),
+        patch("superset.mcp_service.auth.is_feature_enabled", 
return_value=True),
+        patch.object(security_manager, "_is_guest_token_revoked", 
return_value=False),
+        patch.object(
+            security_manager, "get_guest_user_from_token", 
return_value=fake_guest
+        ) as get_guest_user,
+        patch(
+            "superset.mcp_service.auth.load_user_with_relationships",
+            return_value=None,
+        ),
+    ):
+        return _resolve_user_from_jwt_context(app), get_guest_user
+
+
+# -- guest tokens --
+
+
[email protected]("multi_auth", [False, True], ids=["plain", 
"multi_auth"])
[email protected]
+async def test_guest_token_resolves_as_guest(
+    guest_app: SupersetApp, multi_auth: bool
+) -> None:
+    """A real guest token resolves to a GuestUser whether or not the verifier
+    chain is wrapped in ``MultiAuth`` (which qualifies ``client_id``)."""
+    claims = _guest_claims()
+    access_token = await _verify_through_chain(
+        guest_app, _mint_guest_token(claims), multi_auth=multi_auth
+    )
+    assert access_token is not None
+    if multi_auth and MULTI_AUTH_QUALIFIES_CLIENT_ID:
+        # The precondition for the bug: MultiAuth rewrote client_id.
+        assert access_token.client_id != "guest"
+        assert access_token.original_client_id == "guest"
+
+    result, get_guest_user = _resolve_guest(guest_app, access_token)
+
+    assert result is get_guest_user.return_value
+    passed_claims = get_guest_user.call_args.args[0]
+    assert GUEST_TOKEN_CLAIM not in passed_claims
+    assert passed_claims == claims
+
+
[email protected]("multi_auth", [False, True], ids=["plain", 
"multi_auth"])
[email protected]
+async def test_forged_guest_marker_cannot_pose_as_guest(
+    guest_app: SupersetApp, multi_auth: bool
+) -> None:
+    """A token that carries the guest marker and ``client_id="guest"`` but 
whose
+    bearer is not a guest token signed with ``GUEST_TOKEN_JWT_SECRET`` (e.g. an
+    external IdP JWT returned by a source that does not strip the marker) must
+    be rejected, not resolved as a guest."""
+    forged_claims = _guest_claims()
+    forged_raw = _mint_guest_token(forged_claims, secret="idp-signing-key")  # 
noqa: S106
+    forged = AccessToken(
+        token=forged_raw,
+        client_id="guest",
+        scopes=[],
+        claims={GUEST_TOKEN_CLAIM: True, **forged_claims},
+    )
+    access_token: Any = forged
+    if multi_auth:
+        access_token = await 
MultiAuth(verifiers=[_StubVerifier(forged)]).verify_token(
+            forged_raw
+        )
+        assert access_token is not None
+
+    with (
+        patch(
+            "fastmcp.server.dependencies.get_access_token", 
return_value=access_token
+        ),
+        patch("superset.mcp_service.auth.is_feature_enabled", 
return_value=True),
+        patch.object(security_manager, "get_guest_user_from_token") as 
get_guest_user,
+        pytest.raises(ValueError, match="not a valid guest token"),
+    ):
+        _resolve_user_from_jwt_context(guest_app)
+    get_guest_user.assert_not_called()
+
+
+def test_guest_resolution_uses_verified_claims(guest_app: SupersetApp) -> None:
+    """The GuestUser is built from the re-verified bearer, so claims edited on
+    the AccessToken after verification cannot widen the guest's resources."""
+    claims = _guest_claims()
+    tampered = AccessToken(
+        token=_mint_guest_token(claims),
+        client_id="guest",
+        scopes=[],
+        claims={
+            GUEST_TOKEN_CLAIM: True,
+            **claims,
+            "resources": [{"type": "dashboard", "id": "someone-elses"}],
+        },
+    )
+
+    _, get_guest_user = _resolve_guest(guest_app, tampered)
+
+    assert get_guest_user.call_args.args[0]["resources"] == claims["resources"]
+
+
+# -- API-key tokens --
+
+
[email protected]("multi_auth", [False, True])
[email protected]
+async def test_api_key_fast_path_through_multi_auth(
+    app: SupersetApp, multi_auth: bool
+) -> None:
+    """A transport-validated API key wrapped by ``MultiAuth`` defers from the
+    JWT path and loads the validated user on the API-key path."""
+    composite = CompositeTokenVerifier(
+        jwt_verifier=None, api_key_prefixes=["sst_"], app=app
+    )
+    with patch.object(composite, "_validate_api_key_sync", 
return_value=("alice", [])):
+        verifier: Any = MultiAuth(verifiers=[composite]) if multi_auth else 
composite
+        access_token = await verifier.verify_token("sst_abc123")
+    assert access_token is not None
+    # Editing the JWT-style claims must not alter the FAB-validated identity.
+    access_token.claims[API_KEY_VALIDATED_USERNAME_CLAIM] = "admin"
+    if multi_auth and MULTI_AUTH_QUALIFIES_CLIENT_ID:
+        assert access_token.client_id != "api_key"
+
+    alice = MagicMock(username="alice")
+    app.config["FAB_API_KEY_ENABLED"] = True
+    with (
+        patch(
+            "fastmcp.server.dependencies.get_access_token", 
return_value=access_token
+        ),
+        patch(
+            "superset.mcp_service.auth.load_user_with_relationships",
+            return_value=alice,
+        ) as load_user,
+        patch.object(app.appbuilder.sm, "validate_api_key") as validate,
+    ):
+        assert _resolve_user_from_jwt_context(app) is None
+        assert _resolve_user_from_api_key(app) is alice
+    load_user.assert_called_once_with(username="alice")
+    validate.assert_not_called()
+
+
[email protected]
+async def test_api_key_fallback_through_multi_auth(app: SupersetApp) -> None:
+    """A prefix-only API-key pass-through (no transport validation) wrapped by
+    ``MultiAuth`` is still validated against FAB on the API-key path."""
+    composite = CompositeTokenVerifier(
+        jwt_verifier=None, api_key_prefixes=["sst_"], app=None
+    )
+    access_token = await 
MultiAuth(verifiers=[composite]).verify_token("sst_abc123")
+    assert access_token is not None
+
+    alice = MagicMock(username="alice")
+    app.config["FAB_API_KEY_ENABLED"] = True
+    with (
+        patch(
+            "fastmcp.server.dependencies.get_access_token", 
return_value=access_token
+        ),
+        patch.object(
+            app.appbuilder.sm, "validate_api_key", return_value=alice, 
create=True
+        ) as validate,
+        patch(
+            "superset.mcp_service.auth.load_user_with_relationships",
+            return_value=alice,
+        ),
+    ):
+        assert _resolve_user_from_jwt_context(app) is None
+        assert _resolve_user_from_api_key(app) is alice
+    validate.assert_called_once_with("sst_abc123")
+
+
+# -- default user resolver and helper --
+
+
[email protected]
+async def test_default_user_resolver_uses_unqualified_client_id(
+    app: SupersetApp,
+) -> None:
+    """The last-resort ``client_id`` username fallback sees the verifier's own
+    client id, not ``MultiAuth``'s qualified JSON form."""
+    token = AccessToken(token="t", client_id="svc-bot", scopes=[], claims={})  
# noqa: S106
+    wrapped = await 
MultiAuth(verifiers=[_StubVerifier(token)]).verify_token("t")
+    assert wrapped is not None
+
+    assert default_user_resolver(app, wrapped) == "svc-bot"
+
+
+def test_get_verifier_client_id_without_original_attribute() -> None:
+    """Tokens without ``original_client_id`` (FastMCP 3) use ``client_id``."""
+    from superset.mcp_service.composite_token_verifier import 
get_verifier_client_id
+
+    assert get_verifier_client_id(SimpleNamespace(client_id="guest")) == 
"guest"
+    assert get_verifier_client_id(MagicMock(client_id="guest")) == "guest"
+    assert (
+        get_verifier_client_id(
+            SimpleNamespace(
+                client_id='["src","guest",null,null]', 
original_client_id="guest"
+            )
+        )
+        == "guest"
+    )
+    assert get_verifier_client_id(SimpleNamespace()) is None
+
+
[email protected](
+    "chain", ["plain", "composite", "external_first", "composite_first"]
+)
[email protected]
+async def test_idp_api_key_claims_cannot_select_another_user(
+    app: SupersetApp, chain: str
+) -> None:
+    """Internal API-key claims in a signed IdP JWT are not validation 
evidence."""
+    from fastmcp.server.auth.providers.jwt import JWTVerifier
+
+    secret = "independent-idp-signing-secret-for-tests"  # noqa: S105
+    raw = jwt.encode(
+        {
+            "sub": "alice",
+            "client_id": "api_key",
+            "original_client_id": "api_key",
+            "exp": int(time.time()) + 300,
+            API_KEY_PASSTHROUGH_CLAIM: True,
+            API_KEY_VALIDATED_USERNAME_CLAIM: "admin",
+        },
+        secret,
+        algorithm="HS256",
+    )
+    external = JWTVerifier(public_key=secret, algorithm="HS256")
+    composite = CompositeTokenVerifier(external, ["sst_"], app=app)
+    verifier: Any = external
+    if chain == "composite":
+        verifier = composite
+    elif chain == "external_first":
+        verifier = MultiAuth(verifiers=[external, composite])
+    elif chain == "composite_first":
+        verifier = MultiAuth(verifiers=[composite, external])
+    access_token = await verifier.verify_token(raw)
+    assert access_token is not None
+    app.config["FAB_API_KEY_ENABLED"] = True
+    alice = MagicMock(username="alice")
+    with (
+        patch(
+            "fastmcp.server.dependencies.get_access_token", 
return_value=access_token
+        ),
+        patch.object(
+            app.appbuilder.sm, "validate_api_key", return_value=None
+        ) as validate,
+        patch(
+            "superset.mcp_service.auth.load_user_with_relationships", 
return_value=alice
+        ) as load,
+    ):
+        # A stripped token retains its JWT identity; an unwrapped external
+        # source's forged API-key assertion must fail closed at FAB validation.
+        user = _resolve_user_from_jwt_context(app)
+        if user is None:
+            with pytest.raises(PermissionError, match="Invalid or expired API 
key"):
+                _resolve_user_from_api_key(app)
+            validate.assert_called_once_with(raw)
+            load.assert_not_called()
+        else:
+            assert user is alice
+            load.assert_called_once_with("alice")
+            validate.assert_not_called()
+
+
[email protected]("revocation", ["version", "dashboard"])
[email protected]("chain", ["plain", "guest_first", "external_first"])
[email protected]
+async def test_revoked_guest_cannot_bypass_guest_verifier(
+    guest_app: SupersetApp, chain: str, revocation: str
+) -> None:
+    """A different token source cannot bypass core guest-token revocation."""
+    claims = _guest_claims()
+    raw = _mint_guest_token(claims)
+    marked = AccessToken(
+        token=raw,
+        client_id="guest",
+        scopes=[],
+        claims={GUEST_TOKEN_CLAIM: True, **claims},
+    )
+    verifier: Any = _StubVerifier(marked)
+    if chain != "plain":
+        sources = [GuestTokenVerifier(guest_app), verifier]
+        verifier = MultiAuth(
+            verifiers=sources if chain == "guest_first" else sources[::-1]
+        )
+    guest_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = revocation == 
"version"
+    cutoff = SimpleNamespace(guest_token_revoked_before=int(time.time()) + 1)
+    with (
+        patch("superset.is_feature_enabled", return_value=True),
+        patch(
+            
"superset.security.manager.get_current_guest_token_revocation_version",
+            return_value=1,
+        ),
+        patch(
+            "superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
+            return_value=cutoff,
+        ),
+        patch.object(guest_app.appbuilder.sm, "find_role", 
return_value=object()),
+    ):
+        access_token = await verifier.verify_token(raw)
+        assert access_token is not None
+        with (
+            patch(
+                "fastmcp.server.dependencies.get_access_token",
+                return_value=access_token,
+            ),
+            patch("superset.mcp_service.auth.is_feature_enabled", 
return_value=True),
+            patch.object(security_manager, "get_guest_user_from_token") as 
build,
+            pytest.raises(ValueError, match="not a valid guest token"),
+        ):
+            _resolve_user_from_jwt_context(guest_app)
+        build.assert_not_called()
+
+
[email protected]("client_id", ["guest", "api_key"])
+def test_crafted_original_client_id_is_not_validation_evidence(
+    guest_app: SupersetApp, client_id: str
+) -> None:
+    """An original_client_id field cannot authorize either internal 
identity."""
+    raw = _mint_guest_token(_guest_claims(), secret="independent-idp-secret")  
# noqa: S106
+    token = SimpleNamespace(
+        token=raw,
+        client_id="external",
+        original_client_id=client_id,
+        claims={
+            GUEST_TOKEN_CLAIM: True,
+            API_KEY_PASSTHROUGH_CLAIM: True,
+            API_KEY_VALIDATED_USERNAME_CLAIM: "admin",
+        },
+    )
+    guest_app.config["FAB_API_KEY_ENABLED"] = True
+    with (
+        patch("fastmcp.server.dependencies.get_access_token", 
return_value=token),
+        patch("superset.mcp_service.auth.is_feature_enabled", 
return_value=True),
+        patch.object(guest_app.appbuilder.sm, "validate_api_key", 
return_value=None),
+        patch("superset.mcp_service.auth.load_user_with_relationships") as 
load,
+        patch.object(security_manager, "get_guest_user_from_token") as build,
+    ):
+        if client_id == "guest":
+            with pytest.raises(ValueError, match="not a valid guest token"):
+                _resolve_user_from_jwt_context(guest_app)
+        else:
+            assert _resolve_user_from_jwt_context(guest_app) is None
+            with pytest.raises(PermissionError, match="Invalid or expired API 
key"):
+                _resolve_user_from_api_key(guest_app)
+        load.assert_not_called()
+        build.assert_not_called()
+
+
[email protected]("missing", ["user", "resources", "rls_rules", "exp"])
+def test_guest_reverification_requires_core_structure(
+    guest_app: SupersetApp, missing: str
+) -> None:
+    """Other token sources cannot bypass the embedded flow's structural 
checks."""
+    claims = _guest_claims()
+    del claims[missing]
+    token = AccessToken(
+        token=_mint_guest_token(claims),
+        client_id="guest",
+        scopes=[],
+        claims={GUEST_TOKEN_CLAIM: True, **claims},
+    )
+    with pytest.raises(ValueError, match="not a valid guest token"):
+        _resolve_guest(guest_app, token)
+
+
[email protected]("token_type", [None, "access"])
+def test_guest_reverification_rejects_non_guest_type(
+    guest_app: SupersetApp, token_type: str | None
+) -> None:
+    """A correctly signed non-guest JWT must not enter the guest identity 
path."""
+    claims = _guest_claims()
+    claims["type"] = token_type
+    token = AccessToken(
+        token=_mint_guest_token(claims),
+        client_id="guest",
+        scopes=[],
+        claims={GUEST_TOKEN_CLAIM: True, **claims},
+    )
+    with pytest.raises(ValueError, match="not a valid guest token"):
+        _resolve_guest(guest_app, token)
+
+
[email protected]("entrypoint", ["resolve", "tool_list", "tool_search"])
+def test_guest_reverification_observes_later_dashboard_revocation(
+    guest_app: SupersetApp, entrypoint: str
+) -> None:
+    """A prior successful resolution must not mask a later dashboard cutoff."""
+    from superset.mcp_service.server import _tool_allowed_for_current_user
+
+    claims = _guest_claims()
+    token = AccessToken(
+        token=_mint_guest_token(claims),
+        client_id="guest",
+        scopes=[],
+        claims={GUEST_TOKEN_CLAIM: True, **claims},
+    )
+    embedded = SimpleNamespace(guest_token_revoked_before=None)
+    guest_app.config["GUEST_TOKEN_REVOCATION_ENABLED"] = False
+    with (
+        patch("fastmcp.server.dependencies.get_access_token", 
return_value=token),
+        patch("superset.mcp_service.auth.is_feature_enabled", 
return_value=True),
+        patch(
+            "superset.daos.dashboard.EmbeddedDashboardDAO.find_by_id",
+            return_value=embedded,
+        ) as lookup,
+        patch.object(security_manager, "get_guest_user_from_token") as build,
+    ):
+        assert _resolve_user_from_jwt_context(guest_app) is build.return_value
+        embedded.guest_token_revoked_before = claims["iat"] + 1
+        public_tool = SimpleNamespace(fn=lambda: None)
+        if entrypoint == "resolve":
+            with pytest.raises(ValueError, match="not a valid guest token"):
+                _resolve_user_from_jwt_context(guest_app)
+        elif entrypoint == "tool_list":
+            assert RBACToolVisibilityMiddleware._visible_tools([public_tool]) 
== []
+        else:
+            assert not _tool_allowed_for_current_user(public_tool)
+        assert lookup.call_count == 2
+        build.assert_called_once()
+
+
[email protected]("entrypoint", ["resolve", "tool_list", "tool_search"])
+def test_guest_reverification_backend_failure_is_credential_failure(
+    guest_app: SupersetApp, entrypoint: str
+) -> None:
+    """Revocation backend errors reject guest credentials at every entry 
point."""
+    from superset.mcp_service.server import _tool_allowed_for_current_user
+
+    token = AccessToken(
+        token=_mint_guest_token(_guest_claims()),
+        client_id="guest",
+        scopes=[],
+        claims={GUEST_TOKEN_CLAIM: True},
+    )
+    with (
+        patch("fastmcp.server.dependencies.get_access_token", 
return_value=token),
+        patch("superset.mcp_service.auth.is_feature_enabled", 
return_value=True),
+        patch.object(
+            security_manager, "_is_guest_token_revoked", 
side_effect=SQLAlchemyError
+        ),
+        patch.object(security_manager, "get_guest_user_from_token") as build,
+    ):
+        public_tool = SimpleNamespace(fn=lambda: None)
+        if entrypoint == "resolve":
+            with pytest.raises(ValueError, match="not a valid guest token"):
+                _resolve_user_from_jwt_context(guest_app)
+        elif entrypoint == "tool_list":
+            assert RBACToolVisibilityMiddleware._visible_tools([public_tool]) 
== []
+        else:
+            assert not _tool_allowed_for_current_user(public_tool)
+        build.assert_not_called()
+
+
[email protected]("fallback", ["dev", "middleware"])
[email protected]("multi_auth", [False, True])
[email protected](
+    "disabled_setting", ["FAB_API_KEY_ENABLED", "MCP_API_KEY_ENABLED"]
+)
[email protected]
+async def test_disabled_api_key_assertion_cannot_fall_through_to_dev_user(
+    app: SupersetApp, multi_auth: bool, disabled_setting: str, fallback: str
+) -> None:
+    """An explicit API-key assertion must not degrade to a development 
identity."""
+    from superset.mcp_service.auth import get_user_from_request
+
+    token = AccessToken(
+        token="external-bearer",  # noqa: S106
+        client_id="api_key",
+        scopes=[],
+        claims={
+            API_KEY_PASSTHROUGH_CLAIM: True,
+            API_KEY_VALIDATED_USERNAME_CLAIM: "admin",
+        },
+    )
+    if multi_auth:
+        token = await MultiAuth(verifiers=[_StubVerifier(token)]).verify_token(
+            token.token
+        )
+    app.config.update(
+        FAB_API_KEY_ENABLED=True,
+        MCP_API_KEY_ENABLED=None,
+        MCP_DEV_USERNAME="admin" if fallback == "dev" else None,
+    )
+    app.config[disabled_setting] = False
+    with (
+        patch("superset.mcp_service.auth.g", 
SimpleNamespace(user=MagicMock())),
+        patch("fastmcp.server.dependencies.get_access_token", 
return_value=token),
+        patch("superset.mcp_service.auth.load_user_with_relationships") as 
load,
+        pytest.raises(PermissionError, match="Authentication required"),
+    ):
+        get_user_from_request()
+    load.assert_not_called()
+
+
[email protected]
+async def test_serialized_api_key_token_revalidates_bearer(app: SupersetApp) 
-> None:
+    """Losing the internal token type requires FAB validation, not claim 
trust."""
+    composite = CompositeTokenVerifier(None, ["sst_"], app=app)
+    with patch.object(composite, "_validate_api_key_sync", 
return_value=("alice", [])):
+        verified = await composite.verify_token("sst_abc123")
+    assert verified is not None
+    serialized = AccessToken.model_validate(verified.model_dump())
+    serialized.claims[API_KEY_VALIDATED_USERNAME_CLAIM] = "admin"
+    app.config["FAB_API_KEY_ENABLED"] = True
+    alice = MagicMock(username="alice")
+    with (
+        patch("fastmcp.server.dependencies.get_access_token", 
return_value=serialized),
+        patch.object(
+            app.appbuilder.sm, "validate_api_key", return_value=alice
+        ) as validate,
+        patch(
+            "superset.mcp_service.auth.load_user_with_relationships", 
return_value=alice
+        ) as load,
+    ):
+        assert _resolve_user_from_jwt_context(app) is None
+        assert _resolve_user_from_api_key(app) is alice
+    validate.assert_called_once_with("sst_abc123")
+    load.assert_called_once_with(username="alice")
+
+
[email protected]("entrypoint", ["tool_list", "tool_search"])
+def test_disabled_api_key_assertion_hides_tools(
+    app: SupersetApp, entrypoint: str
+) -> None:
+    """Discovery distinguishes rejected credentials from absent 
authentication."""
+    from superset.mcp_service.server import _tool_allowed_for_current_user
+
+    token = AccessToken(
+        token="external-bearer",  # noqa: S106
+        client_id="api_key",
+        scopes=[],
+        claims={API_KEY_PASSTHROUGH_CLAIM: True},
+    )
+    app.config["MCP_API_KEY_ENABLED"] = False
+    public_tool = SimpleNamespace(fn=lambda: None)
+    with (
+        patch("fastmcp.server.dependencies.get_access_token", 
return_value=token),
+        patch("flask.g", SimpleNamespace(user=None)),
+    ):
+        if entrypoint == "tool_list":
+            assert RBACToolVisibilityMiddleware._visible_tools([public_tool]) 
== []
+        else:
+            assert not _tool_allowed_for_current_user(public_tool)

Reply via email to