ploxiln: functionally looks great
diff --git a/tornado/curl_httpclient.py b/tornado/curl_httpclient.py
index 54fc5b36..ef98225c 100644
--- a/tornado/curl_httpclient.py
+++ b/tornado/curl_httpclient.py
@@ -348,8 +348,8 @@ class CurlAsyncHTTPClient(AsyncHTTPClient):
             curl.setopt(pycurl.PROXY, request.proxy_host)
             curl.setopt(pycurl.PROXYPORT, request.proxy_port)
             if request.proxy_username:
-                credentials = '%s:%s' % (request.proxy_username,
-                                         request.proxy_password)
+                credentials = httputil.encode_username_password(request.proxy_username,
+                                                                request.proxy_password)
                 curl.setopt(pycurl.PROXYUSERPWD, credentials)
 
             if (request.proxy_auth_mode is None or
@@ -441,8 +441,6 @@ class CurlAsyncHTTPClient(AsyncHTTPClient):
                 curl.setopt(pycurl.INFILESIZE, len(request.body or ''))
 
         if request.auth_username is not None:
-            userpwd = "%s:%s" % (request.auth_username, request.auth_password or '')
-
             if request.auth_mode is None or request.auth_mode == "basic":
                 curl.setopt(pycurl.HTTPAUTH, pycurl.HTTPAUTH_BASIC)
             elif request.auth_mode == "digest":
@@ -450,7 +448,9 @@ class CurlAsyncHTTPClient(AsyncHTTPClient):
             else:
                 raise ValueError("Unsupported auth_mode %s" % request.auth_mode)
 
-            curl.setopt(pycurl.USERPWD, native_str(userpwd))
+            userpwd = httputil.encode_username_password(request.auth_username,
+                                                        request.auth_password)
+            curl.setopt(pycurl.USERPWD, userpwd)
             curl_log.debug("%s %s (username: %r)", request.method, request.url,
                            request.auth_username)
         else:
diff --git a/tornado/httputil.py b/tornado/httputil.py
index 22a64c31..d1ace5a8 100644
--- a/tornado/httputil.py
+++ b/tornado/httputil.py
@@ -29,11 +29,12 @@ import email.utils
 import numbers
 import re
 import time
+import unicodedata
 import warnings
 
 from tornado.escape import native_str, parse_qs_bytes, utf8
 from tornado.log import gen_log
-from tornado.util import ObjectDict, PY3
+from tornado.util import ObjectDict, PY3, unicode_type
 
 if PY3:
     import http.cookies as Cookie
@@ -949,6 +950,20 @@ def _encode_header(key, pdict):
     return '; '.join(out)
 
 
+def encode_username_password(username, password):
+    """Encodes a username/password pair in the format used by HTTP auth.
+
+    The return value is a byte string in the form ``username:password``.
+
+    .. versionadded:: 5.1
+    """
+    if isinstance(username, unicode_type):
+        username = unicodedata.normalize('NFC', username)
+    if isinstance(password, unicode_type):
+        password = unicodedata.normalize('NFC', password)
+    return utf8(username) + b":" + utf8(password)
+
+
 def doctests():
     import doctest
     return doctest.DocTestSuite()
diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py
index 4df4898a..35c71936 100644
--- a/tornado/simple_httpclient.py
+++ b/tornado/simple_httpclient.py
@@ -1,6 +1,6 @@
 from __future__ import absolute_import, division, print_function
 
-from tornado.escape import utf8, _unicode
+from tornado.escape import _unicode
 from tornado import gen
 from tornado.httpclient import HTTPResponse, HTTPError, AsyncHTTPClient, main, _RequestProxy
 from tornado import httputil
@@ -308,9 +308,9 @@ class _HTTPConnection(httputil.HTTPMessageDelegate):
                     if self.request.auth_mode not in (None, "basic"):
                         raise ValueError("unsupported auth_mode %s",
                                          self.request.auth_mode)
-                    auth = utf8(username) + b":" + utf8(password)
-                    self.request.headers["Authorization"] = (b"Basic " +
-                                                             base64.b64encode(auth))
+                    self.request.headers["Authorization"] = (
+                        b"Basic " + base64.b64encode(
+                            httputil.encode_username_password(username, password)))
                 if self.request.user_agent:
                     self.request.headers["User-Agent"] = self.request.user_agent
                 if not self.request.allow_nonstandard_methods:

Unable to use non-ascii characters in user/password for basic auth in curl_httpclient
Steps to reproduce (Python 3.4):
1. Create tornado.httpclient.HTTPRequest with auth_username or auth_password which contains non-ascii (lower range, 0-128), for example pound sterling £ (which is 153 in ascii).
2. Execute curl_httpclient fetch using that request

Expected result:
1. The request is successfully completed

Actual result:
2. HTTP 599 is returned and internal exception is:
'ascii' codec can't encode character '\xa3' in position 55: ordinal not in range(128)


I am not sure if I am not aware of the proper solution, but I have tried providing bytes as auth_password, but it does not solve the issue because https://github.com/tornadoweb/tornado/blob/master/tornado/curl_httpclient.py#L438 internally uses string formatting. Reading through pycurl docs (http://pycurl.io/docs/latest/unicode.html) suggests that for Python3 bytes array should be used when using curl setopt. It seems like Python3 vs Python2 issue?
diff --git a/tornado/test/curl_httpclient_test.py b/tornado/test/curl_httpclient_test.py
index b7a85952..4230d4cd 100644
--- a/tornado/test/curl_httpclient_test.py
+++ b/tornado/test/curl_httpclient_test.py
@@ -32,13 +32,15 @@ class CurlHTTPClientCommonTestCase(httpclient_test.HTTPClientCommonTestCase):
 
 
 class DigestAuthHandler(RequestHandler):
+    def initialize(self, username, password):
+        self.username = username
+        self.password = password
+
     def get(self):
         realm = 'test'
         opaque = 'asdf'
         # Real implementations would use a random nonce.
         nonce = "1234"
-        username = 'foo'
-        password = 'bar'
 
         auth_header = self.request.headers.get('Authorization', None)
         if auth_header is not None:
@@ -53,9 +55,9 @@ class DigestAuthHandler(RequestHandler):
             assert param_dict['realm'] == realm
             assert param_dict['opaque'] == opaque
             assert param_dict['nonce'] == nonce
-            assert param_dict['username'] == username
+            assert param_dict['username'] == self.username
             assert param_dict['uri'] == self.request.path
-            h1 = md5(utf8('%s:%s:%s' % (username, realm, password))).hexdigest()
+            h1 = md5(utf8('%s:%s:%s' % (self.username, realm, self.password))).hexdigest()
             h2 = md5(utf8('%s:%s' % (self.request.method,
                                      self.request.path))).hexdigest()
             digest = md5(utf8('%s:%s:%s' % (h1, nonce, h2))).hexdigest()
@@ -88,7 +90,8 @@ class CurlHTTPClientTestCase(AsyncHTTPTestCase):
 
     def get_app(self):
         return Application([
-            ('/digest', DigestAuthHandler),
+            ('/digest', DigestAuthHandler, {'username': 'foo', 'password': 'bar'}),
+            ('/digest_non_ascii', DigestAuthHandler, {'username': 'foo', 'password': 'barユ£'}),
             ('/custom_reason', CustomReasonHandler),
             ('/custom_fail_reason', CustomFailReasonHandler),
         ])
@@ -143,3 +146,8 @@ class CurlHTTPClientTestCase(AsyncHTTPTestCase):
                 # during the setup phase doesn't lead the request to
                 # be dropped on the floor.
                 response = self.fetch(u'/ユニコード', raise_error=True)
+
+    def test_digest_auth_non_ascii(self):
+        response = self.fetch('/digest_non_ascii', auth_mode='digest',
+                              auth_username='foo', auth_password='barユ£')
+        self.assertEqual(response.body, b'ok')
diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
index 60c8f490..fb8b12d5 100644
--- a/tornado/test/httpclient_test.py
+++ b/tornado/test/httpclient_test.py
@@ -1,3 +1,4 @@
+# -*- coding: utf-8 -*-
 from __future__ import absolute_import, division, print_function
 
 import base64
@@ -8,6 +9,7 @@ import sys
 import threading
 import datetime
 from io import BytesIO
+import unicodedata
 
 from tornado.escape import utf8, native_str
 from tornado import gen
@@ -237,6 +239,7 @@ Transfer-Encoding: chunked
         self.assertIs(exc_info[0][0], ZeroDivisionError)
 
     def test_basic_auth(self):
+        # This test data appears in section 2 of RFC 7617.
         self.assertEqual(self.fetch("/auth", auth_username="Aladdin",
                                     auth_password="open sesame").body,
                          b"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
@@ -247,6 +250,20 @@ Transfer-Encoding: chunked
                                     auth_mode="basic").body,
                          b"Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==")
 
+    def test_basic_auth_unicode(self):
+        # This test data appears in section 2.1 of RFC 7617.
+        self.assertEqual(self.fetch("/auth", auth_username="test",
+                                    auth_password="123£").body,
+                         b"Basic dGVzdDoxMjPCow==")
+
+        # The standard mandates NFC. Give it a decomposed username
+        # and ensure it is normalized to composed form.
+        username = unicodedata.normalize("NFD", u"josé")
+        self.assertEqual(self.fetch("/auth",
+                                    auth_username=username,
+                                    auth_password="səcrət").body,
+                         b"Basic am9zw6k6c8mZY3LJmXQ=")
+
     def test_unsupported_auth_mode(self):
         # curl and simple clients handle errors a bit differently; the
         # important thing is that they don't fall back to basic auth

pip install -e .[dev]
parse_log_pytest
pytest
pytest flake8
apt-get update apt-get install -y gcc
3.6
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///opt/conda/conda-bld/attrs_1642510447205/work
certifi==2021.5.30
flake8==5.0.4
importlib-metadata==4.2.0
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
mccabe==0.7.0
more-itertools @ file:///tmp/build/80754af9/more-itertools_1637733554872/work
packaging @ file:///tmp/build/80754af9/packaging_1637314298585/work
pluggy @ file:///tmp/build/80754af9/pluggy_1615976315926/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pycodestyle==2.9.1
pyflakes==2.5.0
pyparsing @ file:///tmp/build/80754af9/pyparsing_1635766073266/work
pytest==6.2.4
toml @ file:///tmp/build/80754af9/toml_1616166611790/work
-e git+https://github.com/tornadoweb/tornado.git@6410cd98c1a5e938246a17cac0769f689ed471c5#egg=tornado
typing_extensions @ file:///opt/conda/conda-bld/typing_extensions_1647553014482/work
zipp @ file:///tmp/build/80754af9/zipp_1633618647012/work

name: tornado
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=21.4.0=pyhd3eb1b0_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2021.5.30=py36h06a4308_0
  - importlib_metadata=4.8.1=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.3=he6710b0_2
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - more-itertools=8.12.0=pyhd3eb1b0_0
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=21.3=pyhd3eb1b0_0
  - pip=21.2.2=py36h06a4308_0
  - pluggy=0.13.1=py36h06a4308_0
  - py=1.11.0=pyhd3eb1b0_0
  - pyparsing=3.0.4=pyhd3eb1b0_0
  - pytest=6.2.4=py36h06a4308_2
  - python=3.6.13=h12debd9_1
  - readline=8.2=h5eee18b_0
  - setuptools=58.0.4=py36h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - toml=0.10.2=pyhd3eb1b0_0
  - typing_extensions=4.1.1=pyh06a4308_0
  - wheel=0.37.1=pyhd3eb1b0_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.6.0=pyhd3eb1b0_0
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - flake8==5.0.4
      - importlib-metadata==4.2.0
      - mccabe==0.7.0
      - pycodestyle==2.9.1
      - pyflakes==2.5.0
prefix: /opt/conda/envs/tornado

tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_basic_auth_unicode
tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_304_with_content_length tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_all_methods tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_basic_auth tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_basic_auth_explicit_mode tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_body_encoding tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_body_sanity_checks tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_chunked tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_chunked_close tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_configure_defaults tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_credentials_in_url tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_final_callback_stack_context tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_follow_redirect tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_future_http_error tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_future_http_error_no_raise tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_future_interface tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_header_callback tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_header_callback_stack_context tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_header_types tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_hello_world tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_multi_line_headers tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_non_ascii_header tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_patch_receives_payload tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_post tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_put_307 tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_reuse_request_from_response tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_streaming_callback tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_streaming_stack_context tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_types tornado/test/httpclient_test.py::HTTPClientCommonTestCase::test_unsupported_auth_mode tornado/test/httpclient_test.py::RequestProxyTest::test_bad_attribute tornado/test/httpclient_test.py::RequestProxyTest::test_both_set tornado/test/httpclient_test.py::RequestProxyTest::test_default_set tornado/test/httpclient_test.py::RequestProxyTest::test_defaults_none tornado/test/httpclient_test.py::RequestProxyTest::test_neither_set tornado/test/httpclient_test.py::RequestProxyTest::test_request_set tornado/test/httpclient_test.py::HTTPResponseTestCase::test_str tornado/test/httpclient_test.py::SyncHTTPClientTest::test_sync_client tornado/test/httpclient_test.py::SyncHTTPClientTest::test_sync_client_error tornado/test/httpclient_test.py::HTTPRequestTestCase::test_body tornado/test/httpclient_test.py::HTTPRequestTestCase::test_body_setter tornado/test/httpclient_test.py::HTTPRequestTestCase::test_headers tornado/test/httpclient_test.py::HTTPRequestTestCase::test_headers_setter tornado/test/httpclient_test.py::HTTPRequestTestCase::test_if_modified_since tornado/test/httpclient_test.py::HTTPRequestTestCase::test_null_headers_setter tornado/test/httpclient_test.py::HTTPErrorTestCase::test_copy tornado/test/httpclient_test.py::HTTPErrorTestCase::test_error_with_response tornado/test/httpclient_test.py::HTTPErrorTestCase::test_plain_error
tornado/curl_httpclient.py tornado/httputil.py tornado/simple_httpclient.py
tornado/curl_httpclient.py tornado/httputil.py tornado/simple_httpclient.py