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).
