sadpandajoe commented on code in PR #44365:
URL: https://github.com/apache/superset/pull/44365#discussion_r4192174868


##########
superset/security/login_token.py:
##########
@@ -0,0 +1,290 @@
+# 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.
+"""One-time login tokens for establishing a session inside an iframe.
+
+An SSO redirect flow cannot run inside an iframe: identity providers commonly
+refuse to be framed, and the redirect chain depends on cookies that browsers
+treat as third-party in an embedded context. A parent application that already
+holds a trustworthy proof of the user's identity uses these tokens to turn that
+proof into an ordinary Superset session in two steps:
+
+1. its backend mints a token, presenting a credential that an operator-supplied
+   resolver validates (server-to-server, so the credential never reaches the
+   browser), and
+2. the browser navigates the iframe to the consume endpoint, which exchanges 
the
+   token for a session cookie.
+
+The token is an opaque handle to a short-lived server-side record; no identity
+data travels in the URL.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from datetime import datetime, timedelta
+from typing import Any, Callable, cast, TypedDict
+from uuid import UUID, uuid4
+
+from flask import current_app, Request
+
+from superset.daos.key_value import KeyValueDAO
+from superset.key_value.types import JsonKeyValueCodec, KeyValueResource
+
+logger = logging.getLogger(__name__)
+
+LOGIN_TOKEN_RESOURCE = KeyValueResource.LOGIN_TOKEN
+LOGIN_TOKEN_CODEC = JsonKeyValueCodec()
+
+# Kept short deliberately: the token is handed to a browser as an iframe URL, 
so
+# it lands in access logs and the parent page's DOM. A lifetime measured in
+# seconds bounds the window in which an observer could replay it.
+DEFAULT_LOGIN_TOKEN_TTL_SECONDS = 60
+
+
+class LoginTokenUserInfo(TypedDict, total=False):
+    """The identity a resolver returns for a one-time login token.
+
+    This mirrors the ``userinfo`` contract of Flask-AppBuilder's
+    ``auth_user_oauth``, which the consume step delegates to. ``username``
+    identifies the user, falling back to ``email`` when absent. ``role_keys`` 
are
+    resolved through ``AUTH_ROLES_MAPPING``, so a caller can only ever request
+    roles the operator has already mapped -- authorization stays with the
+    operator rather than moving to the parent application.
+    """
+
+    username: str
+    email: str
+    first_name: str
+    last_name: str
+    role_keys: list[str]
+
+
+LoginTokenIdentityResolver = Callable[..., LoginTokenUserInfo | None]
+
+# Characters a WHATWG URL parser strips before resolving, plus their
+# percent-encoded forms, which some browsers also remove when following a
+# Location header. Normalizing them first stops `/\tx` or `/%09x` smuggling a
+# different target past the checks below.
+_URL_STRIPPED_CONTROL_CHARS = re.compile(r"[\t\n\r]|%09|%0[ADad]")
+
+
+def is_safe_next_path(url: str) -> bool:
+    """Whether ``url`` is a site-relative path this endpoint may redirect to.
+
+    Deliberately stricter than :func:`superset.utils.link_redirect.
+    is_safe_redirect_url`, which resolves "internal" against
+    ``WEBDRIVER_BASEURL`` / ``WEBDRIVER_BASEURL_USER_FRIENDLY``. Those are
+    report-worker settings defaulting to ``http://0.0.0.0:8080/`` and need bear
+    no relation to the public origin, so a deployment that has never configured
+    reports would see a legitimate same-origin absolute URL rejected and be
+    redirected to ``/`` instead of the requested dashboard.
+
+    Requiring a relative path avoids the question entirely: the parent
+    application always knows the path it wants, the browser resolves it against
+    Superset's own origin, and there is no host to compare.
+    """
+    if not url or not url.strip():
+        return False
+
+    normalized = _URL_STRIPPED_CONTROL_CHARS.sub("", url.strip())
+    # Browsers treat backslashes as forward slashes in special schemes, so
+    # `/\evil.com` would resolve as the protocol-relative `//evil.com`.
+    normalized = normalized.replace("\\", "/")
+
+    # A single leading slash, and nothing that could be read as a host or a
+    # scheme. `//host`, `https://host` and `mailto:x` are all rejected.
+    return normalized.startswith("/") and not normalized.startswith("//")
+
+
+def is_enabled() -> bool:
+    """Whether the flow is usable: feature flag on and a resolver 
configured."""
+    # Deferred: ``superset/__init__`` imports the app factory, so a 
module-level
+    # import here would be circular via superset.daos / superset.security.
+    from superset import (  # pylint: disable=import-outside-toplevel
+        is_feature_enabled,
+    )
+
+    if not is_feature_enabled("LOGIN_TOKEN"):
+        return False
+
+    return current_app.config.get("LOGIN_TOKEN_IDENTITY_RESOLVER") is not None
+
+
+def get_ttl_seconds() -> int:
+    """The configured token lifetime, falling back to the default."""
+    configured = current_app.config.get(
+        "LOGIN_TOKEN_TTL_SECONDS", DEFAULT_LOGIN_TOKEN_TTL_SECONDS
+    )
+    try:
+        ttl = int(configured)
+    except (TypeError, ValueError):
+        logger.warning(
+            "LOGIN_TOKEN_TTL_SECONDS is not an integer (%r); using %s",
+            configured,
+            DEFAULT_LOGIN_TOKEN_TTL_SECONDS,
+        )
+        return DEFAULT_LOGIN_TOKEN_TTL_SECONDS
+
+    return ttl if ttl > 0 else DEFAULT_LOGIN_TOKEN_TTL_SECONDS
+
+
+# Each rejection below is a distinct failure with its own diagnostic — an
+# unconfigured resolver is normal and silent, a misconfigured or misbehaving 
one
+# is logged. Merging the branches to satisfy the return-count limit would lose
+# that distinction, which is the only signal an operator gets.
+def resolve_identity(  # pylint: disable=too-many-return-statements
+    request: Request, **kwargs: Any
+) -> LoginTokenUserInfo | None:
+    """Run the operator-supplied resolver against an inbound mint request.
+
+    The resolver is the whole authentication boundary for minting: it decides
+    what counts as proof of identity (an OIDC id token, an existing session, a
+    credential checked against an internal service). A resolver that raises, or
+    returns something other than a mapping, is treated as a rejection rather
+    than a server error, so a failing validation can never be mistaken for a
+    successful one.
+    """
+    resolver = current_app.config.get("LOGIN_TOKEN_IDENTITY_RESOLVER")
+    if resolver is None:
+        return None
+
+    if not callable(resolver):
+        logger.error("LOGIN_TOKEN_IDENTITY_RESOLVER is not callable")
+        return None
+
+    try:
+        userinfo = resolver(request, **kwargs)
+    except Exception:  # pylint: disable=broad-except
+        logger.exception("LOGIN_TOKEN_IDENTITY_RESOLVER rejected the request")
+        return None
+
+    if not userinfo:
+        return None
+
+    if not isinstance(userinfo, dict):
+        # A truthy non-mapping would otherwise raise on the ``.get`` below and
+        # surface as a 500, contradicting the rejection contract above.
+        logger.error(
+            "LOGIN_TOKEN_IDENTITY_RESOLVER returned %s, expected a mapping",
+            type(userinfo).__name__,
+        )
+        return None
+
+    # Normalize before validating, because ``auth_user_oauth`` selects on key
+    # *presence* rather than truthiness: ``if "username" in userinfo`` wins 
even
+    # when the value is empty, and the empty username is then rejected 
outright.
+    # A resolver returning {"username": "", "email": "[email protected]"} would
+    # otherwise mint a perfectly good token that always fails redemption with a
+    # 401, instead of falling back to the email. Stripping and dropping empty
+    # string values keeps this check and FAB's in agreement.
+    userinfo = {
+        key: value.strip() if isinstance(value, str) else value
+        for key, value in userinfo.items()
+        if not (isinstance(value, str) and not value.strip())
+    }
+
+    if not (userinfo.get("username") or userinfo.get("email")):
+        # auth_user_oauth derives the username from one of these two; without
+        # either there is no identity to provision.
+        logger.error("LOGIN_TOKEN_IDENTITY_RESOLVER returned no username or 
email")
+        return None
+
+    # The resolver is operator-supplied and typed Any, so the isinstance guard
+    # above narrows it only as far as a plain dict. Its keys are the resolver's
+    # contract, checked here to the extent that matters (a usable identifier);
+    # anything extra is ignored by auth_user_oauth.
+    return cast(LoginTokenUserInfo, userinfo)
+
+
+def mint(userinfo: LoginTokenUserInfo) -> tuple[str, datetime]:
+    """Store ``userinfo`` behind a fresh opaque token.
+
+    Returns the token and its expiry. The token is a ``uuid4``, so it carries 
no
+    identity data and is not guessable.
+    """
+    token = uuid4()
+    expires_on = datetime.now() + timedelta(seconds=get_ttl_seconds())
+
+    # Opportunistic GC rather than collision avoidance: a fresh uuid4 cannot
+    # collide, but with a TTL measured in seconds these rows turn over quickly,
+    # and the scheduled prune job runs far less often than tokens are minted.
+    # Minting is not a hot path, so one indexed DELETE here is cheap insurance
+    # against the table filling with dead entries between prunes.
+    KeyValueDAO.delete_expired_entries(LOGIN_TOKEN_RESOURCE)
+    KeyValueDAO.create_entry(
+        resource=LOGIN_TOKEN_RESOURCE,
+        value=dict(userinfo),
+        codec=LOGIN_TOKEN_CODEC,
+        key=token,
+        expires_on=expires_on,
+    )
+    return str(token), expires_on
+
+
+def consume(token: str) -> LoginTokenUserInfo | None:
+    """Exchange a token for its ``userinfo``, burning it durably.
+
+    The entry is row-locked before it is read, so two concurrent requests 
cannot
+    both observe it: the second blocks until the first commits and then finds 
it
+    gone.
+
+    The delete is then **committed here**, rather than left to the caller's 
unit
+    of work. Flask-AppBuilder's ``add_user`` / ``update_user`` catch their own
+    failures, call ``rollback()`` on this same session and return ``False``
+    without raising -- and ``update_user_auth_stat`` ignores that return value
+    entirely. A provisioning error, or a failing ``user_updating`` signal
+    handler, would therefore silently undo a pending delete and leave a token
+    that has already been handed to a browser redeemable again. Committing the
+    burn first makes it independent of anything provisioning does, including a
+    login that ultimately succeeds.
+
+    Returns ``None`` for an unknown, malformed, or expired token -- callers 
must
+    not distinguish between those cases in their response.
+    """
+    from superset import db  # pylint: disable=import-outside-toplevel
+
+    try:
+        key = UUID(token)
+    except (AttributeError, TypeError, ValueError):
+        return None
+
+    entry = KeyValueDAO.get_entry(LOGIN_TOKEN_RESOURCE, key, for_update=True)
+    if entry is None:
+        return None
+
+    # Delete regardless of expiry: an expired token is spent either way, and
+    # leaving it behind would only wait for the pruning job.
+    expired = entry.is_expired()
+    try:
+        userinfo = LOGIN_TOKEN_CODEC.decode(entry.value)
+    except Exception:  # pylint: disable=broad-except
+        logger.exception("Unable to decode stored login token payload")
+        userinfo = None
+
+    KeyValueDAO.delete_entry(LOGIN_TOKEN_RESOURCE, key)

Review Comment:
   On SQLite (the default metastore) `with_for_update()` compiles to nothing, 
and the result of `delete_entry` is ignored here. Two overlapping GETs can both 
read and decode the same token; one deletes and commits, the other's delete 
finds no row, returns `False`, and it still returns the identity, so both 
requests establish a session from one token. Could the burn itself be the gate 
(delete and require exactly one affected row, otherwise return `None`), with 
`FOR UPDATE` only as a backstop?



##########
tests/integration_tests/security/login_token_api_tests.py:
##########
@@ -0,0 +1,337 @@
+# 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.
+"""Route-level tests for the one-time login token endpoints.
+
+These complement ``tests/unit_tests/security/login_token_test.py``, which
+exercises mint and consume directly. Driving the HTTP routes additionally 
covers
+the decorator stack -- ``@transaction()`` in particular, which does not nest --
+and the session cookie that ``login_user`` writes, neither of which the unit
+tests can see.
+"""
+
+from typing import Any
+from unittest.mock import patch
+from uuid import UUID
+
+from flask_appbuilder.security.sqla.manager import user_updating
+
+from superset import db
+from superset.daos.key_value import KeyValueDAO
+from superset.key_value.models import KeyValueEntry
+from superset.key_value.types import KeyValueResource
+from superset.utils import json
+from tests.conftest import with_config
+from tests.integration_tests.base_tests import SupersetTestCase
+from tests.integration_tests.conftest import with_feature_flags
+from tests.integration_tests.constants import GAMMA_USERNAME
+
+ENDPOINT = "/api/v1/security/login-token/"
+MINT_SECRET = "integration-test-secret"  # noqa: S105
+
+
+def _resolver(request: Any, **kwargs: Any) -> dict[str, Any] | None:
+    """Stand-in for a deployment resolver: a shared header plus a username.
+
+    A real one would validate an id token or call an internal service; all this
+    needs to do is distinguish an authorized caller from an unauthorized one.
+    """
+    if request.headers.get("X-Test-Mint-Secret") != MINT_SECRET:
+        return None
+    username = (request.get_json(silent=True) or {}).get("username")
+    return {"username": username} if username else None
+
+
+def _verbatim_resolver(request: Any, **kwargs: Any) -> dict[str, Any] | None:
+    """Return the request body as ``userinfo``, unmodified.
+
+    Lets a test drive an arbitrary resolver result through the real route, 
which
+    is the only way to cover shapes a sensible resolver would not produce but a
+    misbehaving one will.
+    """
+    if request.headers.get("X-Test-Mint-Secret") != MINT_SECRET:
+        return None
+    return request.get_json(silent=True) or None
+
+
+class TestLoginTokenApi(SupersetTestCase):
+    def _mint(self, username: str = GAMMA_USERNAME) -> str:
+        """Mint a token through the route and return it."""
+        response = self.client.post(
+            ENDPOINT,
+            data=json.dumps({"username": username}),
+            content_type="application/json",
+            headers={"X-Test-Mint-Secret": MINT_SECRET},
+        )
+        assert response.status_code == 200, response.data
+        token = json.loads(response.data)["access_token"]
+        assert token
+        return token
+
+    @staticmethod
+    def _stored_tokens() -> int:
+        return (
+            db.session.query(KeyValueEntry)
+            .filter(KeyValueEntry.resource == 
KeyValueResource.LOGIN_TOKEN.value)
+            .count()
+        )
+
+    @staticmethod
+    def _is_stored(token: str) -> bool:
+        """Whether this specific token still has a row.
+
+        Scoped to one key rather than counting the table: the metadata database
+        is shared across the suite, so an absolute count couples these
+        assertions to whatever other tests happen to have left behind.
+        """
+        return (
+            KeyValueDAO.get_entry(KeyValueResource.LOGIN_TOKEN, UUID(token)) 
is not None
+        )
+
+    # ---------------------------------------------------------------- closed 
off
+
+    def test_endpoints_are_404_without_the_feature_flag(self):
+        """Closed by default: neither verb exists until the flag is on."""
+        assert self.client.post(ENDPOINT).status_code == 404
+        assert self.client.get(f"{ENDPOINT}?token=x").status_code == 404
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": None})
+    def test_endpoints_are_404_without_a_resolver(self):
+        """The flag alone is not enough -- a resolver must also be 
configured."""
+        assert self.client.post(ENDPOINT).status_code == 404
+        assert self.client.get(f"{ENDPOINT}?token=x").status_code == 404
+
+    # --------------------------------------------------------------------- 
mint
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_mint_rejects_a_caller_the_resolver_declines(self):
+        """No shared header, no token -- and nothing written to the store."""
+        before = self._stored_tokens()
+        response = self.client.post(
+            ENDPOINT,
+            data=json.dumps({"username": GAMMA_USERNAME}),
+            content_type="application/json",
+        )
+        assert response.status_code == 401
+        assert self._stored_tokens() == before
+
+    # ------------------------------------------------------------------ 
consume
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_consume_establishes_a_session_and_redirects(self):
+        """The happy path: 302 to `next`, and the frame is authenticated."""
+        token = self._mint()
+        response = 
self.client.get(f"{ENDPOINT}?token={token}&next=/dashboard/list/")
+
+        assert response.status_code == 302
+        assert response.headers["Location"].endswith("/dashboard/list/")
+
+        # The session belongs to the resolved user, not to whoever called mint.
+        me = self.client.get("/api/v1/me/")
+        assert me.status_code == 200
+        assert json.loads(me.data)["result"]["username"] == GAMMA_USERNAME
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_consume_is_single_use(self):
+        """A spent token is refused on the second request."""
+        token = self._mint()
+        assert self.client.get(f"{ENDPOINT}?token={token}").status_code == 302
+        assert self.client.get(f"{ENDPOINT}?token={token}").status_code == 401
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_consume_burn_survives_a_provisioning_rollback(self):
+        """Burn durability when provisioning *declines*.
+
+        Flask-AppBuilder's ``add_user`` / ``update_user`` catch their own
+        failures, call ``rollback()`` on the shared session and return 
``False``
+        without raising -- and ``update_user_auth_stat`` discards that return
+        value. Before ``consume`` committed the delete itself, that rollback
+        discarded the pending DELETE, and a token already handed to a browser
+        became redeemable a second time.
+
+        This covers the 401 branch. The harder case -- a rollback on a login 
that
+        nonetheless *succeeds* -- is
+        :meth:`test_consume_burn_survives_a_failing_user_updating_hook`.
+        """
+        token = self._mint()
+
+        def rollback_and_decline(_userinfo: Any) -> None:
+            db.session.rollback()
+            return None
+
+        with patch.object(
+            self.app.appbuilder.sm,
+            "auth_user_oauth",
+            side_effect=rollback_and_decline,
+        ):
+            first = self.client.get(f"{ENDPOINT}?token={token}")
+
+        # Provisioning declined, so no session -- but the token is still spent.
+        assert first.status_code == 401
+
+        second = self.client.get(f"{ENDPOINT}?token={token}")
+        assert second.status_code == 401, (
+            "the token was redeemable after a provisioning rollback -- the 
burn "
+            "was not committed independently of provisioning"
+        )
+        assert not self._is_stored(token)
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_consume_burn_survives_a_failing_user_updating_hook(self):
+        """Burn durability on a login that *succeeds* despite a rollback.
+
+        This is the worst case, and it uses the real Flask-AppBuilder code path
+        rather than a patched security manager. In FAB 5.2.2,
+        ``SecurityManager.update_user`` emits ``user_updating`` as a pre-commit
+        signal *inside* its ``try``, and ``_emit_pre_signal`` re-raises 
whatever a
+        handler throws. The handler's exception therefore lands in
+        ``update_user``'s own ``except``, which calls ``rollback()`` on the 
shared
+        session and returns ``False``.
+
+        Nothing upstream notices: ``update_user_auth_stat`` discards that 
return
+        value, and ``auth_user_oauth`` returns the user regardless. So the 
request
+        completes as a **successful login** -- a 302 and a valid session 
cookie --
+        while the rollback has silently undone whatever the endpoint had 
pending.
+
+        If the burn were not committed inside ``consume``, the token would be 
back
+        in the key-value store and redeemable for the rest of its TTL, even 
though
+        the user is now logged in. ``FAB_SECURITY_SIGNALS_ENABLED`` defaults to
+        ``True`` and Superset does not override it, so this is reachable in a
+        default deployment, not a contrived one.
+        """
+        token = self._mint()
+
+        def failing_hook(_sender: Any, **_kwargs: Any) -> None:
+            raise RuntimeError("user_updating handler failed")
+
+        # ``connected_to`` holds a strong reference for the duration; a plain
+        # ``connect`` of a local function can be garbage collected before the
+        # signal fires, which would silently make this test vacuous.
+        with user_updating.connected_to(failing_hook):
+            first = self.client.get(f"{ENDPOINT}?token={token}")
+
+        # The login SUCCEEDED -- that is precisely what makes this dangerous.
+        assert first.status_code == 302, (
+            f"expected a successful login despite the failing hook, got "
+            f"{first.status_code}"
+        )
+
+        second = self.client.get(f"{ENDPOINT}?token={token}")
+        assert second.status_code == 401, (
+            "the token was redeemable after a successful login whose "
+            "user_updating hook rolled the session back -- the burn must be "
+            "committed before provisioning runs"
+        )
+        assert not self._is_stored(token)
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_consume_rejects_unknown_and_malformed_tokens(self):
+        """Missing, malformed and well-formed-but-unknown are the same 401."""
+        for token in ("", "not-a-uuid", 
"6d2b9921-2274-43b8-94d6-e5e1f05372c4"):
+            with self.subTest(token=token):
+                response = self.client.get(f"{ENDPOINT}?token={token}")
+                assert response.status_code == 401
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _verbatim_resolver})
+    def test_empty_identity_keys_do_not_mint_a_doomed_token(self):
+        """A token that mints must be redeemable, end to end.
+
+        ``auth_user_oauth`` selects the identifier on key *presence*: ``if
+        "username" in userinfo`` wins even when the value is empty, and the 
empty
+        username is then rejected outright. So a resolver returning
+        ``{"username": "", "email": ...}`` used to mint a perfectly good token
+        that could only ever 401 on redemption, instead of falling back to the
+        email. Surrounding whitespace failed the same way, via ``find_user``.
+
+        Both now normalize before minting, so each of these redeems. Note FAB
+        uses whichever value it selected as the ``find_user`` lookup key, which
+        is why the email slot here holds a username rather than an address.
+        """
+        for userinfo in (
+            {"username": "", "email": GAMMA_USERNAME},
+            {"username": f"  {GAMMA_USERNAME}  "},
+            {"username": "", "first_name": "", "email": GAMMA_USERNAME},
+        ):
+            with self.subTest(userinfo=userinfo):
+                response = self.client.post(
+                    ENDPOINT,
+                    data=json.dumps(userinfo),
+                    content_type="application/json",
+                    headers={"X-Test-Mint-Secret": MINT_SECRET},
+                )
+                assert response.status_code == 200, response.data
+                token = json.loads(response.data)["access_token"]
+
+                redeemed = self.client.get(f"{ENDPOINT}?token={token}")
+                assert redeemed.status_code == 302, (
+                    f"minted a token for {userinfo} that could not be redeemed 
"
+                    f"({redeemed.status_code}) -- empty or padded identity 
keys "
+                    "must be normalized before minting"
+                )
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _verbatim_resolver})
+    def test_mint_rejects_an_identity_with_no_usable_identifier(self):
+        """If nothing survives normalization there is no identity to mint 
for."""
+        for userinfo in (
+            {"username": "", "email": ""},
+            {"username": "   "},
+            {"first_name": "Jane", "last_name": "Doe"},
+        ):
+            with self.subTest(userinfo=userinfo):
+                response = self.client.post(
+                    ENDPOINT,
+                    data=json.dumps(userinfo),
+                    content_type="application/json",
+                    headers={"X-Test-Mint-Secret": MINT_SECRET},
+                )
+                assert response.status_code == 401, response.data
+
+    # --------------------------------------------------------------------- 
next
+
+    @with_feature_flags(LOGIN_TOKEN=True)
+    @with_config({"LOGIN_TOKEN_IDENTITY_RESOLVER": _resolver})
+    def test_consume_falls_back_to_root_for_a_non_relative_next(self):
+        """`next` is site-relative only; anything else lands on `/`.
+
+        Absolute URLs are rejected even for the deployment's own origin. That 
is
+        deliberate: comparing against ``WEBDRIVER_BASEURL`` would have made a
+        legitimate same-origin URL depend on report-worker configuration.
+        """
+        for requested_next in (
+            "https://superset.example.com/dashboard/1/";,
+            "//evil.example.com/",
+            "/\\evil.example.com/",
+            "javascript:alert(1)",
+        ):
+            with self.subTest(next=requested_next):
+                token = self._mint()
+                response = self.client.get(
+                    f"{ENDPOINT}?token={token}&next={requested_next}"
+                )
+
+                assert response.status_code == 302
+                location = response.headers["Location"]
+                assert location.endswith("/"), location

Review Comment:
   For the input `https://superset.example.com/dashboard/1/`, redirecting to 
that URL unchanged still satisfies `endswith("/")` and the `evil.example.com` 
check, so this test passes even if the relative-only fallback is lost in the 
route. Could it assert the `Location` is exactly `/` for each rejected `next`?



##########
superset/config.py:
##########
@@ -384,6 +384,11 @@ def _try_json_readsha(filepath: str, length: int) -> str | 
None:
     "superset.dashboards.api.cache_dashboard_screenshot",
     "superset.views.core.log",
     "superset.views.datasource.views.samples",
+    # Minting a one-time login token is a server-to-server call from a trusted
+    # parent application's backend, authenticated by
+    # LOGIN_TOKEN_IDENTITY_RESOLVER rather than by a session cookie. There is 
no
+    # session to forge against, and such a caller has no CSRF token to present.
+    "superset.security.api.login_token",

Review Comment:
   The integration test config disables CSRF and none of the new route tests 
turn it on, so removing or misnaming this exemption would leave them green 
while the server-to-server POST starts failing with a CSRF 400 in production. 
Could a test enable CSRF, send the mint POST with no session or CSRF token, and 
assert 200 with a redeemable token (and 401 rather than a CSRF error for bad 
credentials)?



##########
superset/security/login_token.py:
##########
@@ -0,0 +1,290 @@
+# 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.
+"""One-time login tokens for establishing a session inside an iframe.
+
+An SSO redirect flow cannot run inside an iframe: identity providers commonly
+refuse to be framed, and the redirect chain depends on cookies that browsers
+treat as third-party in an embedded context. A parent application that already
+holds a trustworthy proof of the user's identity uses these tokens to turn that
+proof into an ordinary Superset session in two steps:
+
+1. its backend mints a token, presenting a credential that an operator-supplied
+   resolver validates (server-to-server, so the credential never reaches the
+   browser), and
+2. the browser navigates the iframe to the consume endpoint, which exchanges 
the
+   token for a session cookie.
+
+The token is an opaque handle to a short-lived server-side record; no identity
+data travels in the URL.
+"""
+
+from __future__ import annotations
+
+import logging
+import re
+from datetime import datetime, timedelta
+from typing import Any, Callable, cast, TypedDict
+from uuid import UUID, uuid4
+
+from flask import current_app, Request
+
+from superset.daos.key_value import KeyValueDAO

Review Comment:
   This module imports `KeyValueDAO` at module scope, which pulls in 
`KeyValueEntry`, whose `relationship(security_manager.user_model, ...)` is 
evaluated at import. The config example tells operators to `from 
superset.security.login_token import LoginTokenUserInfo` inside 
`superset_config.py`, which loads before `appbuilder.sm` exists, so following 
the documented example would fail at startup. Could the DAO import move into 
`mint`/`consume`, or the resolver types live somewhere with no DB-model import?



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to