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)