Repository: maxcountryman/flask-login (python)

Issue:
address deprecations from Werkzeug 2.3
**Describe the bug**
https://github.com/maxcountryman/flask-login/blob/b30758205211c3af4ac6a41601aee17f6aa45f64/src/flask_login/utils.py#L15 is deprecated and will be removed in Werkzeug 2.4

```
/.venv/lib/python3.9/site-packages/flask_login/utils.py:130: DeprecationWarning: 'werkzeug.urls.url_encode' is deprecated and will be removed in Werkzeug 2.4. Use 'urllib.parse.urlencode' instead.
    netloc=netloc, query=url_encode(md, sort=True)
```


**Additional context**
N/A

Pull request description:
fix: avoid DeprecationWarning 'werkzeug.urls.url_decode' is deprecated
close #744 

Patch:
diff --git a/.github/workflows/pre-commit.yaml b/.github/workflows/pre-commit.yaml
index 4f6ac98..c801e59 100644
--- a/.github/workflows/pre-commit.yaml
+++ b/.github/workflows/pre-commit.yaml
@@ -7,7 +7,7 @@ jobs:
   pre-commit:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: actions/setup-python@v4
         with:
           python-version: '3.10'
diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml
index abaf247..cd1c1f9 100644
--- a/.github/workflows/publish-release.yml
+++ b/.github/workflows/publish-release.yml
@@ -6,7 +6,7 @@ jobs:
   build:
     runs-on: ubuntu-latest
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: actions/setup-python@v4
         with:
           python-version: '3.10'
@@ -21,7 +21,7 @@ jobs:
       - run: python -m build
       - run: twine check dist/*
       - name: Publish to PyPI
-        uses: pypa/gh-action-pypi-publish@v1.8.5
+        uses: pypa/gh-action-pypi-publish@v1.8.10
         with:
           user: __token__
           password: ${{ secrets.PYPI_API_TOKEN }}
diff --git a/requirements/ci-release.txt b/requirements/ci-release.txt
index c387d46..18c4001 100644
--- a/requirements/ci-release.txt
+++ b/requirements/ci-release.txt
@@ -9,7 +9,7 @@ bleach==4.1.0
     # via readme-renderer
 build==0.7.0
     # via -r ci-release.in
-certifi==2022.12.7
+certifi==2023.7.22
     # via requests
 cffi==1.15.0
     # via cryptography
@@ -17,7 +17,7 @@ charset-normalizer==2.0.12
     # via requests
 colorama==0.4.4
     # via twine
-cryptography==39.0.1
+cryptography==41.0.4
     # via secretstorage
 docutils==0.18.1
     # via readme-renderer
@@ -43,7 +43,7 @@ pkginfo==1.8.2
     # via twine
 pycparser==2.21
     # via cffi
-pygments==2.11.2
+pygments==2.15.0
     # via readme-renderer
 pyparsing==3.0.7
     # via packaging
@@ -51,7 +51,7 @@ readme-renderer==34.0
     # via
     #   -r ci-release.in
     #   twine
-requests==2.27.1
+requests==2.31.0
     # via
     #   requests-toolbelt
     #   twine
diff --git a/requirements/dev.txt b/requirements/dev.txt
index b0639c8..431efe8 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -15,7 +15,7 @@ babel==2.9.1
     # via sphinx
 blinker==1.4
     # via -r tests.in
-certifi==2022.12.7
+certifi==2023.7.22
     # via requests
 cfgv==3.3.1
     # via pre-commit
@@ -72,7 +72,7 @@ py==1.11.0
     # via
     #   pytest
     #   tox
-pygments==2.11.2
+pygments==2.15.0
     # via sphinx
 pyparsing==3.0.7
     # via packaging
@@ -82,7 +82,7 @@ pytz==2022.1
     # via babel
 pyyaml==6.0
     # via pre-commit
-requests==2.27.1
+requests==2.31.0
     # via sphinx
 semantic-version==2.9.0
     # via -r tests.in
diff --git a/requirements/docs.txt b/requirements/docs.txt
index 26f7331..4d13d2e 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -9,7 +9,7 @@ alabaster==0.7.12
     # via sphinx
 babel==2.9.1
     # via sphinx
-certifi==2022.12.7
+certifi==2023.7.22
     # via requests
 charset-normalizer==2.0.12
     # via requests
@@ -25,13 +25,13 @@ markupsafe==2.1.1
     # via jinja2
 packaging==21.3
     # via sphinx
-pygments==2.11.2
+pygments==2.15.0
     # via sphinx
 pyparsing==3.0.7
     # via packaging
 pytz==2022.1
     # via babel
-requests==2.27.1
+requests==2.31.0
     # via sphinx
 snowballstemmer==2.2.0
     # via sphinx
diff --git a/setup.py b/setup.py
index e069432..00a5ac2 100644
--- a/setup.py
+++ b/setup.py
@@ -5,6 +5,6 @@ setup(
     name="Flask-Login",
     install_requires=[
         "Flask>=1.0.4",
-        "Werkzeug>=1.0.1",
+        "Werkzeug>=2.3.0",
     ],
 )
diff --git a/src/flask_login/utils.py b/src/flask_login/utils.py
index c3c4684..45a7e27 100644
--- a/src/flask_login/utils.py
+++ b/src/flask_login/utils.py
@@ -3,6 +3,8 @@ from functools import wraps
 from hashlib import sha512
 from urllib.parse import urlparse
 from urllib.parse import urlunparse
+from urllib.parse import parse_qs
+from urllib.parse import urlencode
 
 from flask import current_app
 from flask import g
@@ -11,8 +13,6 @@ from flask import request
 from flask import session
 from flask import url_for
 from werkzeug.local import LocalProxy
-from werkzeug.urls import url_decode
-from werkzeug.urls import url_encode
 
 from .config import COOKIE_NAME
 from .config import EXEMPT_METHODS
@@ -123,11 +123,11 @@ def login_url(login_view, next_url=None, next_field="next"):
         return base
 
     parsed_result = urlparse(base)
-    md = url_decode(parsed_result.query)
+    md = parse_qs(parsed_result.query)
     md[next_field] = make_next_param(base, next_url)
     netloc = current_app.config.get("FORCE_HOST_FOR_REDIRECTS") or parsed_result.netloc
     parsed_result = parsed_result._replace(
-        netloc=netloc, query=url_encode(md, sort=True)
+        netloc=netloc, query=urlencode(md, doseq=True)
     )
     return urlunparse(parsed_result)
 


Test patch:
diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml
index 2c6f41c..341cee6 100644
--- a/.github/workflows/tests.yaml
+++ b/.github/workflows/tests.yaml
@@ -21,7 +21,7 @@ jobs:
           - {name: 'PyPy', python: 'pypy-3.8', tox: pypy38}
           - {name: 'Minimum Versions', python: '3.9', tox: py-min}
     steps:
-      - uses: actions/checkout@v3
+      - uses: actions/checkout@v4
       - uses: actions/setup-python@v4
         with:
           python-version: ${{ matrix.python }}
diff --git a/requirements/ci-tests.txt b/requirements/ci-tests.txt
index 7fc7ba4..f0f16a1 100644
--- a/requirements/ci-tests.txt
+++ b/requirements/ci-tests.txt
@@ -5,7 +5,7 @@
 #
 #    pip-compile-multi
 #
-certifi==2022.12.7
+certifi==2023.7.22
     # via requests
 charset-normalizer==2.0.12
     # via requests
@@ -33,7 +33,7 @@ py==1.11.0
     # via tox
 pyparsing==3.0.7
     # via packaging
-requests==2.27.1
+requests==2.31.0
     # via coveralls
 six==1.16.0
     # via
diff --git a/tests/test_login.py b/tests/test_login.py
index cbe58ee..7c074d6 100644
--- a/tests/test_login.py
+++ b/tests/test_login.py
@@ -669,24 +669,16 @@ class LoginTestCase(unittest.TestCase):
         name = self.app.config["REMEMBER_COOKIE_NAME"] = "myname"
         duration = self.app.config["REMEMBER_COOKIE_DURATION"] = timedelta(days=2)
         path = self.app.config["REMEMBER_COOKIE_PATH"] = "/mypath"
-        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = ".localhost.local"
+        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = "localhost.local"
 
         with self.app.test_client() as c:
             c.get("/login-notch-remember")
 
-            # TODO: Is there a better way to test this?
-            self.assertIn(
-                domain,
-                c.cookie_jar._cookies,
-                "Custom domain not found as cookie domain",
-            )
-            domain_cookie = c.cookie_jar._cookies[domain]
-            self.assertIn(path, domain_cookie, "Custom path not found as cookie path")
-            path_cookie = domain_cookie[path]
-            self.assertIn(name, path_cookie, "Custom name not found as cookie name")
-            cookie = path_cookie[name]
+            cookie = c.get_cookie(key=name, domain=domain, path=path)
+            self.assertIsNotNone(cookie,
+                                 "Custom domain, path and name not found in cookies")
 
-            expiration_date = datetime.utcfromtimestamp(cookie.expires)
+            expiration_date = datetime.utcfromtimestamp(cookie.expires.timestamp())
             expected_date = datetime.utcnow() + duration
             difference = expected_date - expiration_date
 
@@ -702,24 +694,16 @@ class LoginTestCase(unittest.TestCase):
         self.app.config["REMEMBER_COOKIE_DURATION"] = 172800
         duration = timedelta(hours=7)
         path = self.app.config["REMEMBER_COOKIE_PATH"] = "/mypath"
-        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = ".localhost.local"
+        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = "localhost.local"
 
         with self.app.test_client() as c:
             c.get("/login-notch-remember-custom")
 
-            # TODO: Is there a better way to test this?
-            self.assertIn(
-                domain,
-                c.cookie_jar._cookies,
-                "Custom domain not found as cookie domain",
-            )
-            domain_cookie = c.cookie_jar._cookies[domain]
-            self.assertIn(path, domain_cookie, "Custom path not found as cookie path")
-            path_cookie = domain_cookie[path]
-            self.assertIn(name, path_cookie, "Custom name not found as cookie name")
-            cookie = path_cookie[name]
+            cookie = c.get_cookie(key=name, domain=domain, path=path)
+            self.assertIsNotNone(cookie,
+                                 "Custom domain, path and name not found in cookies")
 
-            expiration_date = datetime.utcfromtimestamp(cookie.expires)
+            expiration_date = datetime.utcfromtimestamp(cookie.expires.timestamp())
             expected_date = datetime.utcnow() + duration
             difference = expected_date - expiration_date
 
@@ -734,15 +718,15 @@ class LoginTestCase(unittest.TestCase):
         self.app.config["REMEMBER_COOKIE_DURATION"] = 172800
         duration = timedelta(seconds=172800)
         name = self.app.config["REMEMBER_COOKIE_NAME"] = "myname"
-        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = ".localhost.local"
+        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = "localhost.local"
 
         with self.app.test_client() as c:
             result = c.get("/login-notch-remember")
             self.assertEqual(result.status_code, 200)
 
-            cookie = c.cookie_jar._cookies[domain]["/"][name]
+            cookie = c.get_cookie(key=name, domain=domain, path="/")
 
-            expiration_date = datetime.utcfromtimestamp(cookie.expires)
+            expiration_date = datetime.utcfromtimestamp(cookie.expires.timestamp())
             expected_date = datetime.utcnow() + duration
             difference = expected_date - expiration_date
 
@@ -794,25 +778,23 @@ class LoginTestCase(unittest.TestCase):
         self.assertIn(expected_exception_message, str(cm.exception))
 
     def test_remember_me_refresh_every_request(self):
-        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = ".localhost.local"
+        domain = self.app.config["REMEMBER_COOKIE_DOMAIN"] = "localhost.local"
         path = self.app.config["REMEMBER_COOKIE_PATH"] = "/"
 
         # No refresh
         self.app.config["REMEMBER_COOKIE_REFRESH_EACH_REQUEST"] = False
         with self.app.test_client() as c:
             c.get("/login-notch-remember")
-            self.assertIn("remember", c.cookie_jar._cookies[domain][path])
-            expiration_date_1 = datetime.utcfromtimestamp(
-                c.cookie_jar._cookies[domain][path]["remember"].expires
-            )
+            cookie = c.get_cookie(key="remember", domain=domain, path=path)
+            self.assertIsNotNone(cookie)
+            expiration_date_1 = datetime.utcfromtimestamp(cookie.expires.timestamp())
 
             self._delete_session(c)
 
             c.get("/username")
-            self.assertIn("remember", c.cookie_jar._cookies[domain][path])
-            expiration_date_2 = datetime.utcfromtimestamp(
-                c.cookie_jar._cookies[domain][path]["remember"].expires
-            )
+            cookie = c.get_cookie(key="remember", domain=domain, path=path)
+            self.assertIsNotNone(cookie)
+            expiration_date_2 = datetime.utcfromtimestamp(cookie.expires.timestamp())
             self.assertEqual(expiration_date_1, expiration_date_2)
 
         # With refresh (mock datetime's `utcnow`)
@@ -823,19 +805,19 @@ class LoginTestCase(unittest.TestCase):
 
             with self.app.test_client() as c:
                 c.get("/login-notch-remember")
-                self.assertIn("remember", c.cookie_jar._cookies[domain][path])
+                cookie = c.get_cookie(key="remember", domain=domain, path=path)
                 expiration_date_1 = datetime.utcfromtimestamp(
-                    c.cookie_jar._cookies[domain][path]["remember"].expires
+                    cookie.expires.timestamp()
                 )
                 self.assertIsNotNone(expiration_date_1)
 
                 self._delete_session(c)
 
                 mock_dt.utcnow = Mock(return_value=now + timedelta(seconds=1))
-                c.get("/username")
-                self.assertIn("remember", c.cookie_jar._cookies[domain][path])
+                c.get("/login-notch-remember")
+                cookie = c.get_cookie(key="remember", domain=domain, path=path)
                 expiration_date_2 = datetime.utcfromtimestamp(
-                    c.cookie_jar._cookies[domain][path]["remember"].expires
+                    cookie.expires.timestamp()
                 )
                 self.assertIsNotNone(expiration_date_2)
                 self.assertNotEqual(expiration_date_1, expiration_date_2)