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

vavila pushed a commit to branch feat/oauth-single-refresh-tokens
in repository https://gitbox.apache.org/repos/asf/superset.git

commit 9b934f37b716bcdf9b7f6ad531d7bb0857548486
Author: Vitor Avila <[email protected]>
AuthorDate: Tue Mar 3 14:24:55 2026 -0300

    feat: Support OAuth2 single-use refresh tokens
---
 superset/utils/oauth2.py               |  4 +++
 tests/unit_tests/utils/oauth2_tests.py | 56 ++++++++++++++++++++++++++++++++++
 2 files changed, 60 insertions(+)

diff --git a/superset/utils/oauth2.py b/superset/utils/oauth2.py
index 57cc0a25ce9..4978c0af5c5 100644
--- a/superset/utils/oauth2.py
+++ b/superset/utils/oauth2.py
@@ -167,6 +167,10 @@ def refresh_oauth2_token(
         token.access_token_expiration = datetime.now() + timedelta(
             seconds=token_response["expires_in"]
         )
+        # Support single-use refresh tokens
+        if new_refresh_token := token_response.get("refresh_token"):
+            token.refresh_token = new_refresh_token
+
         db.session.add(token)
 
     return token.access_token
diff --git a/tests/unit_tests/utils/oauth2_tests.py 
b/tests/unit_tests/utils/oauth2_tests.py
index 08b7cc9c6e7..f04ae26e7c2 100644
--- a/tests/unit_tests/utils/oauth2_tests.py
+++ b/tests/unit_tests/utils/oauth2_tests.py
@@ -188,6 +188,62 @@ def test_refresh_oauth2_token_no_access_token_in_response(
     assert result is None
 
 
+def test_refresh_oauth2_token_updates_refresh_token(
+    mocker: MockerFixture,
+) -> None:
+    """
+    Test that refresh_oauth2_token updates the refresh token when a new one is 
returned.
+
+    Some OAuth2 providers issue single-use refresh tokens, where each token 
refresh
+    response includes a new refresh token that replaces the previous one.
+    """
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.DistributedLock")
+    db_engine_spec = mocker.MagicMock()
+    db_engine_spec.get_oauth2_fresh_token.return_value = {
+        "access_token": "new-access-token",
+        "expires_in": 3600,
+        "refresh_token": "new-refresh-token",
+    }
+    token = mocker.MagicMock()
+    token.refresh_token = "old-refresh-token"  # noqa: S105
+
+    with freeze_time("2024-01-01"):
+        refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
+
+    assert token.access_token == "new-access-token"  # noqa: S105
+    assert token.access_token_expiration == datetime(2024, 1, 1, 1)
+    assert token.refresh_token == "new-refresh-token"  # noqa: S105
+    db.session.add.assert_called_with(token)
+
+
+def test_refresh_oauth2_token_keeps_refresh_token(
+    mocker: MockerFixture,
+) -> None:
+    """
+    Test that refresh_oauth2_token keeps the existing refresh token when none 
returned.
+
+    When the OAuth2 provider does not issue a new refresh token in the 
response,
+    the original refresh token should be preserved.
+    """
+    db = mocker.patch("superset.utils.oauth2.db")
+    mocker.patch("superset.utils.oauth2.DistributedLock")
+    db_engine_spec = mocker.MagicMock()
+    db_engine_spec.get_oauth2_fresh_token.return_value = {
+        "access_token": "new-access-token",
+        "expires_in": 3600,
+    }
+    token = mocker.MagicMock()
+    token.refresh_token = "original-refresh-token"  # noqa: S105
+
+    with freeze_time("2024-01-01"):
+        refresh_oauth2_token(DUMMY_OAUTH2_CONFIG, 1, 1, db_engine_spec, token)
+
+    assert token.access_token == "new-access-token"  # noqa: S105
+    assert token.refresh_token == "original-refresh-token"  # noqa: S105
+    db.session.add.assert_called_with(token)
+
+
 def test_generate_code_verifier_length() -> None:
     """
     Test that generate_code_verifier produces a string of valid length (RFC 
7636).

Reply via email to