diff --git a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py index 485fa9df9..e4243b3a2 100644 --- a/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py +++ b/airbyte_cdk/sources/streams/http/requests_native_auth/oauth.py @@ -348,20 +348,23 @@ def refresh_and_set_access_token(self) -> None: """Force refresh the access token and update internal state. For single-use refresh tokens, this also persists the new refresh token - and emits a control message to update the connector config. + and emits a control message to update the connector config. If the + response omits a refresh token, the existing one is preserved. """ new_access_token, access_token_expires_in, new_refresh_token = self.refresh_access_token() self.access_token = new_access_token - self.set_refresh_token(new_refresh_token) + if new_refresh_token is not None: + self.set_refresh_token(new_refresh_token) self.set_token_expiry_date(access_token_expires_in) self._emit_control_message() - def refresh_access_token(self) -> Tuple[str, AirbyteDateTime, str]: # type: ignore[override] + def refresh_access_token(self) -> Tuple[str, AirbyteDateTime, Optional[str]]: # type: ignore[override] """ Refreshes the access token by making a handled request and extracting the necessary token information. Returns: - Tuple[str, str, str]: A tuple containing the new access token, token expiry date, and refresh token. + A tuple of (access_token, token_expiry_date, refresh_token). The refresh token + is `None` when the OAuth provider omits it from the response. """ response_json = self._make_handled_request() return ( diff --git a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py index f1d5cc0fb..0eabac267 100644 --- a/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py +++ b/unit_tests/sources/streams/http/requests_native_auth/test_requests_native_auth.py @@ -898,6 +898,35 @@ def test_send_refresh_request_as_query_params_picks_up_rotated_refresh_token( second_call_body = mocked_request.call_args.kwargs["data"] assert "refresh_token" not in second_call_body + def test_missing_refresh_token_in_response_preserves_existing_token( + self, mocker, connector_config + ): + """When the OAuth response omits the refresh token, the existing refresh token is preserved.""" + original_refresh_token = connector_config["credentials"]["refresh_token"] + authenticator = SingleUseRefreshTokenOauth2Authenticator( + connector_config, + token_refresh_endpoint="https://refresh_endpoint.com", + client_id=connector_config["credentials"]["client_id"], + client_secret=connector_config["credentials"]["client_secret"], + ) + + resp.status_code = 200 + mocker.patch.object( + resp, + "json", + return_value={ + authenticator.get_access_token_name(): "new_access_token", + authenticator.get_expires_in_name(): 3600, + # no refresh_token in response + }, + ) + mocker.patch.object(requests, "request", side_effect=mock_request, autospec=True) + + authenticator.refresh_and_set_access_token() + + assert authenticator.access_token == "new_access_token" + assert authenticator.get_refresh_token() == original_refresh_token + def mock_request(method, url, data, headers, **kwargs): if url == "https://refresh_endpoint.com":