From 308ce68f9f31fb6ae59075665c06ea35b6a6c6dc Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Mon, 3 Apr 2023 12:14:16 +0100
Subject: [PATCH 01/17] Implement MSC3882 revision 1

---
 synapse/rest/client/capabilities.py           |  5 ++++
 synapse/rest/client/login.py                  | 16 ++++++++--
 synapse/rest/client/login_token_request.py    | 10 +++----
 synapse/rest/client/versions.py               |  2 --
 tests/rest/client/test_capabilities.py        | 30 +++++++++++++++++++
 tests/rest/client/test_login.py               | 23 ++++++++++++++
 tests/rest/client/test_login_token_request.py |  8 ++---
 7 files changed, 81 insertions(+), 13 deletions(-)

diff --git a/synapse/rest/client/capabilities.py b/synapse/rest/client/capabilities.py
index e84dde31b118..11fc0b067899 100644
--- a/synapse/rest/client/capabilities.py
+++ b/synapse/rest/client/capabilities.py
@@ -82,6 +82,11 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 "enabled": self.config.experimental.msc3664_enabled,
             }
 
+        if self.config.experimental.msc3882_enabled:
+            response["capabilities"]["org.matrix.msc3882.get_login_token"] = {
+                "enabled": True,
+            }
+
         return HTTPStatus.OK, response
 
 
diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index b7e9c8f6b572..896cf2cdbe47 100644
--- a/synapse/rest/client/login.py
+++ b/synapse/rest/client/login.py
@@ -107,6 +107,9 @@ def __init__(self, hs: "HomeServer"):
             and hs.config.experimental.msc3866.require_approval_for_new_accounts
         )
 
+        # Whether MSC3882 get login token is enabled.
+        self._get_login_token_enabled = hs.config.experimental.msc3882_enabled
+
         self.auth = hs.get_auth()
 
         self.clock = hs.get_clock()
@@ -145,7 +148,12 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             # to SSO.
             flows.append({"type": LoginRestServlet.CAS_TYPE})
 
-        if self.cas_enabled or self.saml2_enabled or self.oidc_enabled:
+        if (
+            self.cas_enabled
+            or self.saml2_enabled
+            or self.oidc_enabled
+            or self._get_login_token_enabled
+        ):
             flows.append(
                 {
                     "type": LoginRestServlet.SSO_TYPE,
@@ -163,7 +171,11 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             # don't know how to implement, since they (currently) will always
             # fall back to the fallback API if they don't understand one of the
             # login flow types returned.
-            flows.append({"type": LoginRestServlet.TOKEN_TYPE})
+            tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
+            # If MSC3882 is enabled we advertise the get_login_token flag.
+            if self._get_login_token_enabled:
+                tokenTypeFlow["org.matrix.msc3882.get_login_token"] = True
+            flows.append(tokenTypeFlow)
 
         flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types())
 
diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index 43ea21d5e6ac..2d8726ac4cf1 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -33,7 +33,7 @@ class LoginTokenRequestServlet(RestServlet):
 
     Request:
 
-    POST /login/token HTTP/1.1
+    POST /login/get_token HTTP/1.1
     Content-Type: application/json
 
     {}
@@ -43,12 +43,12 @@ class LoginTokenRequestServlet(RestServlet):
     HTTP/1.1 200 OK
     {
         "login_token": "ABDEFGH",
-        "expires_in": 3600,
+        "expires_in_ms": 3600000,
     }
     """
 
     PATTERNS = client_patterns(
-        "/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True
+        "/org.matrix.msc3882/login/get_token$", releases=[], v1=False, unstable=True
     )
 
     def __init__(self, hs: "HomeServer"):
@@ -77,7 +77,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
 
         login_token = await self.auth_handler.create_login_token_for_user_id(
             user_id=requester.user.to_string(),
-            auth_provider_id="org.matrix.msc3882.login_token_request",
+            auth_provider_id="org.matrix.msc3882.get_login_token",
             duration_ms=self.token_timeout,
         )
 
@@ -85,7 +85,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             200,
             {
                 "login_token": login_token,
-                "expires_in": self.token_timeout // 1000,
+                "expires_in_ms": self.token_timeout,
             },
         )
 
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index 59aed66464e5..ecd84f435f4f 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -112,8 +112,6 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
                     "fi.mau.msc2815": self.config.experimental.msc2815_enabled,
                     # Adds a ping endpoint for appservices to check HS->AS connection
                     "fi.mau.msc2659": self.config.experimental.msc2659_enabled,
-                    # Adds support for login token requests as per MSC3882
-                    "org.matrix.msc3882": self.config.experimental.msc3882_enabled,
                     # Adds support for remotely enabling/disabling pushers, as per MSC3881
                     "org.matrix.msc3881": self.config.experimental.msc3881_enabled,
                     # Adds support for filtering /messages by event relation.
diff --git a/tests/rest/client/test_capabilities.py b/tests/rest/client/test_capabilities.py
index c16e8d43f419..bc33854b229b 100644
--- a/tests/rest/client/test_capabilities.py
+++ b/tests/rest/client/test_capabilities.py
@@ -186,3 +186,33 @@ def test_get_does_include_msc3244_fields_when_enabled(self) -> None:
             self.assertGreater(len(details["support"]), 0)
             for room_version in details["support"]:
                 self.assertTrue(room_version in KNOWN_ROOM_VERSIONS, str(room_version))
+
+    def test_get_does_not_include_msc3882_fields_when_disabled(self) -> None:
+        access_token = self.get_success(
+            self.auth_handler.create_access_token_for_user_id(
+                self.user, device_id=None, valid_until_ms=None
+            )
+        )
+
+        channel = self.make_request("GET", self.url, access_token=access_token)
+        capabilities = channel.json_body["capabilities"]
+
+        self.assertEqual(channel.code, HTTPStatus.OK)
+        self.assertTrue(
+            "org.matrix.msc3882.get_login_token" not in capabilities
+            or not capabilities["org.matrix.msc3882.get_login_token"]["enabled"]
+        )
+
+    @override_config({"experimental_features": {"msc3882_enabled": True}})
+    def test_get_does_include_msc3882_fields_when_enabled(self) -> None:
+        access_token = self.get_success(
+            self.auth_handler.create_access_token_for_user_id(
+                self.user, device_id=None, valid_until_ms=None
+            )
+        )
+
+        channel = self.make_request("GET", self.url, access_token=access_token)
+        capabilities = channel.json_body["capabilities"]
+
+        self.assertEqual(channel.code, HTTPStatus.OK)
+        self.assertTrue(capabilities["org.matrix.msc3882.get_login_token"]["enabled"])
diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py
index 62acf4f44ed0..69b463890047 100644
--- a/tests/rest/client/test_login.py
+++ b/tests/rest/client/test_login.py
@@ -446,6 +446,29 @@ def test_require_approval(self) -> None:
             ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
         )
 
+    def test_get_login_flows_with_msc3882_disabled(self) -> None:
+        """GET /login should return m.login.token without get_login_token true"""
+        channel = self.make_request("GET", "/_matrix/client/r0/login")
+        self.assertEqual(channel.code, 200, channel.result)
+
+        flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
+        self.assertTrue(
+            "m.login.token" not in flows
+            or "org.matrix.msc3882.get_login_token" not in flows["m.login.token"]
+            or not flows["m.login.token"]["org.matrix.msc3882.get_login_token"]
+        )
+
+    @override_config({"experimental_features": {"msc3882_enabled": True}})
+    def test_get_login_flows_with_msc3882_enabled(self) -> None:
+        """GET /login should return m.login.token without get_login_token true"""
+        channel = self.make_request("GET", "/_matrix/client/r0/login")
+        self.assertEqual(channel.code, 200, channel.result)
+
+        print(channel.json_body)
+
+        flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
+        self.assertTrue(flows["m.login.token"]["org.matrix.msc3882.get_login_token"])
+
 
 @skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC")
 class MultiSSOTestCase(unittest.HomeserverTestCase):
diff --git a/tests/rest/client/test_login_token_request.py b/tests/rest/client/test_login_token_request.py
index b8187db982bf..cdf4134cbed1 100644
--- a/tests/rest/client/test_login_token_request.py
+++ b/tests/rest/client/test_login_token_request.py
@@ -22,7 +22,7 @@
 from tests import unittest
 from tests.unittest import override_config
 
-endpoint = "/_matrix/client/unstable/org.matrix.msc3882/login/token"
+endpoint = "/_matrix/client/unstable/org.matrix.msc3882/login/get_token"
 
 
 class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
@@ -82,7 +82,7 @@ def test_uia_on(self) -> None:
 
         channel = self.make_request("POST", endpoint, uia, access_token=token)
         self.assertEqual(channel.code, 200)
-        self.assertEqual(channel.json_body["expires_in"], 300)
+        self.assertEqual(channel.json_body["expires_in_ms"], 300000)
 
         login_token = channel.json_body["login_token"]
 
@@ -103,7 +103,7 @@ def test_uia_off(self) -> None:
 
         channel = self.make_request("POST", endpoint, {}, access_token=token)
         self.assertEqual(channel.code, 200)
-        self.assertEqual(channel.json_body["expires_in"], 300)
+        self.assertEqual(channel.json_body["expires_in_ms"], 300000)
 
         login_token = channel.json_body["login_token"]
 
@@ -130,4 +130,4 @@ def test_expires_in(self) -> None:
 
         channel = self.make_request("POST", endpoint, {}, access_token=token)
         self.assertEqual(channel.code, 200)
-        self.assertEqual(channel.json_body["expires_in"], 15)
+        self.assertEqual(channel.json_body["expires_in_ms"], 15000)

From 97e4775c6cf04c23a8b0e790907ceb5d49b26d73 Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Tue, 4 Apr 2023 16:54:20 +0100
Subject: [PATCH 02/17] Changelog

---
 changelog.d/15388.feature | 1 +
 1 file changed, 1 insertion(+)
 create mode 100644 changelog.d/15388.feature

diff --git a/changelog.d/15388.feature b/changelog.d/15388.feature
new file mode 100644
index 000000000000..cdfd11164786
--- /dev/null
+++ b/changelog.d/15388.feature
@@ -0,0 +1 @@
+Experimental implementation of revision 1 of [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882) to allow an existing device/session to generate a login token for use on a new device/session.
\ No newline at end of file

From 54fe012f8e2ae2cda7dc1ec12c922870aedf8e24 Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Tue, 4 Apr 2023 17:38:47 +0100
Subject: [PATCH 03/17] Fix advertised flows when SSO is not in use

---
 synapse/rest/client/login.py    | 21 ++++++++-------------
 tests/rest/client/test_login.py | 12 ++++++++----
 2 files changed, 16 insertions(+), 17 deletions(-)

diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index 896cf2cdbe47..e04d4f242527 100644
--- a/synapse/rest/client/login.py
+++ b/synapse/rest/client/login.py
@@ -148,12 +148,10 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             # to SSO.
             flows.append({"type": LoginRestServlet.CAS_TYPE})
 
-        if (
-            self.cas_enabled
-            or self.saml2_enabled
-            or self.oidc_enabled
-            or self._get_login_token_enabled
-        ):
+        # MSC3882 requires m.login.token to be advertised
+        supportLoginTokenFlow = self._get_login_token_enabled
+
+        if self.cas_enabled or self.saml2_enabled or self.oidc_enabled:
             flows.append(
                 {
                     "type": LoginRestServlet.SSO_TYPE,
@@ -164,13 +162,10 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 }
             )
 
-            # While it's valid for us to advertise this login type generally,
-            # synapse currently only gives out these tokens as part of the
-            # SSO login flow.
-            # Generally we don't want to advertise login flows that clients
-            # don't know how to implement, since they (currently) will always
-            # fall back to the fallback API if they don't understand one of the
-            # login flow types returned.
+            # SSO requires a login token to be generated, so we need to advertise that flow
+            supportLoginTokenFlow = True
+
+        if supportLoginTokenFlow:
             tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
             # If MSC3882 is enabled we advertise the get_login_token flag.
             if self._get_login_token_enabled:
diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py
index 69b463890047..6f4135eea0d5 100644
--- a/tests/rest/client/test_login.py
+++ b/tests/rest/client/test_login.py
@@ -464,10 +464,14 @@ def test_get_login_flows_with_msc3882_enabled(self) -> None:
         channel = self.make_request("GET", "/_matrix/client/r0/login")
         self.assertEqual(channel.code, 200, channel.result)
 
-        print(channel.json_body)
-
-        flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
-        self.assertTrue(flows["m.login.token"]["org.matrix.msc3882.get_login_token"])
+        self.assertCountEqual(
+            channel.json_body["flows"],
+            [
+                {"type": "m.login.token", "org.matrix.msc3882.get_login_token": True},
+                {"type": "m.login.password"},
+                {"type": "m.login.application_service"},
+            ],
+        )
 
 
 @skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC")

From 5d75875c588f3af67b5b15e36de2f0d32a2d4db6 Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Wed, 24 May 2023 10:06:04 -0400
Subject: [PATCH 04/17] Update config.

---
 synapse/config/auth.py                        | 10 ++++++++++
 synapse/config/experimental.py                |  7 -------
 synapse/rest/client/capabilities.py           |  8 +++-----
 synapse/rest/client/login.py                  | 16 ++++++++--------
 synapse/rest/client/login_token_request.py    |  8 ++++----
 tests/rest/client/test_capabilities.py        | 14 ++++++--------
 tests/rest/client/test_login.py               | 14 +++++---------
 tests/rest/client/test_login_token_request.py | 14 +++++++-------
 8 files changed, 43 insertions(+), 48 deletions(-)

diff --git a/synapse/config/auth.py b/synapse/config/auth.py
index 35774962c0be..92fffef1c36d 100644
--- a/synapse/config/auth.py
+++ b/synapse/config/auth.py
@@ -53,3 +53,13 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
         self.ui_auth_session_timeout = self.parse_duration(
             ui_auth.get("session_timeout", 0)
         )
+
+        # Logging in with an existing session.
+        login_via_existing = config.get("login_via_existing_session", {})
+        self.login_via_existing_enabled = login_via_existing.get("enabled", False)
+        self.login_via_existing_require_ui_auth = login_via_existing.get(
+            "require_ui_auth", True
+        )
+        self.login_via_existing_token_timeout = self.parse_duration(
+            login_via_existing.get("token_timeout", "5m")
+        )
diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index d769b7f6686e..8daa23721972 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -118,13 +118,6 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:
         # MSC3881: Remotely toggle push notifications for another client
         self.msc3881_enabled: bool = experimental.get("msc3881_enabled", False)
 
-        # MSC3882: Allow an existing session to sign in a new session
-        self.msc3882_enabled: bool = experimental.get("msc3882_enabled", False)
-        self.msc3882_ui_auth: bool = experimental.get("msc3882_ui_auth", True)
-        self.msc3882_token_timeout = self.parse_duration(
-            experimental.get("msc3882_token_timeout", "5m")
-        )
-
         # MSC3874: Filtering /messages with rel_types / not_rel_types.
         self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False)
 
diff --git a/synapse/rest/client/capabilities.py b/synapse/rest/client/capabilities.py
index a77b0697b786..3154b9f77e95 100644
--- a/synapse/rest/client/capabilities.py
+++ b/synapse/rest/client/capabilities.py
@@ -65,6 +65,9 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 "m.3pid_changes": {
                     "enabled": self.config.registration.enable_3pid_changes
                 },
+                "m.get_login_token": {
+                    "enabled": self.config.auth.login_via_existing_enabled,
+                },
             }
         }
 
@@ -83,11 +86,6 @@ async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 "enabled": self.config.experimental.msc3664_enabled,
             }
 
-        if self.config.experimental.msc3882_enabled:
-            response["capabilities"]["org.matrix.msc3882.get_login_token"] = {
-                "enabled": True,
-            }
-
         return HTTPStatus.OK, response
 
 
diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index 7d7714abb8d9..fed61abecbd5 100644
--- a/synapse/rest/client/login.py
+++ b/synapse/rest/client/login.py
@@ -104,8 +104,8 @@ def __init__(self, hs: "HomeServer"):
             and hs.config.experimental.msc3866.require_approval_for_new_accounts
         )
 
-        # Whether MSC3882 get login token is enabled.
-        self._get_login_token_enabled = hs.config.experimental.msc3882_enabled
+        # Whether get login token is enabled.
+        self._get_login_token_enabled = hs.config.auth.login_via_existing_enabled
 
         self.auth = hs.get_auth()
 
@@ -145,8 +145,8 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             # to SSO.
             flows.append({"type": LoginRestServlet.CAS_TYPE})
 
-        # MSC3882 requires m.login.token to be advertised
-        supportLoginTokenFlow = self._get_login_token_enabled
+        # The login token flow requires m.login.token to be advertised.
+        support_login_token_flow = self._get_login_token_enabled
 
         if self.cas_enabled or self.saml2_enabled or self.oidc_enabled:
             flows.append(
@@ -160,13 +160,13 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             )
 
             # SSO requires a login token to be generated, so we need to advertise that flow
-            supportLoginTokenFlow = True
+            support_login_token_flow = True
 
-        if supportLoginTokenFlow:
+        if support_login_token_flow:
             tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
-            # If MSC3882 is enabled we advertise the get_login_token flag.
+            # If the login token flow is enabled advertise the get_login_token flag.
             if self._get_login_token_enabled:
-                tokenTypeFlow["org.matrix.msc3882.get_login_token"] = True
+                tokenTypeFlow["m.get_login_token"] = True
             flows.append(tokenTypeFlow)
 
         flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types())
diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index 2d8726ac4cf1..44501f3614a7 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -58,15 +58,15 @@ def __init__(self, hs: "HomeServer"):
         self.clock = hs.get_clock()
         self.server_name = hs.config.server.server_name
         self.auth_handler = hs.get_auth_handler()
-        self.token_timeout = hs.config.experimental.msc3882_token_timeout
-        self.ui_auth = hs.config.experimental.msc3882_ui_auth
+        self.token_timeout = hs.config.auth.login_via_existing_token_timeout
+        self._require_ui_auth = hs.config.auth.login_via_existing_require_ui_auth
 
     @interactive_auth_handler
     async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
         requester = await self.auth.get_user_by_req(request)
         body = parse_json_object_from_request(request)
 
-        if self.ui_auth:
+        if self._require_ui_auth:
             await self.auth_handler.validate_user_via_ui_auth(
                 requester,
                 request,
@@ -91,5 +91,5 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
 
 
 def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
-    if hs.config.experimental.msc3882_enabled:
+    if hs.config.auth.login_via_existing_enabled:
         LoginTokenRequestServlet(hs).register(http_server)
diff --git a/tests/rest/client/test_capabilities.py b/tests/rest/client/test_capabilities.py
index bc33854b229b..cf23430f6ad4 100644
--- a/tests/rest/client/test_capabilities.py
+++ b/tests/rest/client/test_capabilities.py
@@ -187,7 +187,8 @@ def test_get_does_include_msc3244_fields_when_enabled(self) -> None:
             for room_version in details["support"]:
                 self.assertTrue(room_version in KNOWN_ROOM_VERSIONS, str(room_version))
 
-    def test_get_does_not_include_msc3882_fields_when_disabled(self) -> None:
+    def test_get_get_token_login_fields_when_disabled(self) -> None:
+        """By default login via an existing session is disabled."""
         access_token = self.get_success(
             self.auth_handler.create_access_token_for_user_id(
                 self.user, device_id=None, valid_until_ms=None
@@ -198,13 +199,10 @@ def test_get_does_not_include_msc3882_fields_when_disabled(self) -> None:
         capabilities = channel.json_body["capabilities"]
 
         self.assertEqual(channel.code, HTTPStatus.OK)
-        self.assertTrue(
-            "org.matrix.msc3882.get_login_token" not in capabilities
-            or not capabilities["org.matrix.msc3882.get_login_token"]["enabled"]
-        )
+        self.assertFalse(capabilities["m.get_login_token"]["enabled"])
 
-    @override_config({"experimental_features": {"msc3882_enabled": True}})
-    def test_get_does_include_msc3882_fields_when_enabled(self) -> None:
+    @override_config({"login_via_existing_session": {"enabled": True}})
+    def test_get_get_token_login_fields_when_enabled(self) -> None:
         access_token = self.get_success(
             self.auth_handler.create_access_token_for_user_id(
                 self.user, device_id=None, valid_until_ms=None
@@ -215,4 +213,4 @@ def test_get_does_include_msc3882_fields_when_enabled(self) -> None:
         capabilities = channel.json_body["capabilities"]
 
         self.assertEqual(channel.code, HTTPStatus.OK)
-        self.assertTrue(capabilities["org.matrix.msc3882.get_login_token"]["enabled"])
+        self.assertTrue(capabilities["m.get_login_token"]["enabled"])
diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py
index 41c1a16e6eff..8f518761700c 100644
--- a/tests/rest/client/test_login.py
+++ b/tests/rest/client/test_login.py
@@ -446,20 +446,16 @@ def test_require_approval(self) -> None:
             ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
         )
 
-    def test_get_login_flows_with_msc3882_disabled(self) -> None:
+    def test_get_login_flows_with_login_via_existing_disabled(self) -> None:
         """GET /login should return m.login.token without get_login_token true"""
         channel = self.make_request("GET", "/_matrix/client/r0/login")
         self.assertEqual(channel.code, 200, channel.result)
 
         flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
-        self.assertTrue(
-            "m.login.token" not in flows
-            or "org.matrix.msc3882.get_login_token" not in flows["m.login.token"]
-            or not flows["m.login.token"]["org.matrix.msc3882.get_login_token"]
-        )
+        self.assertNotIn("m.login.token", flows)
 
-    @override_config({"experimental_features": {"msc3882_enabled": True}})
-    def test_get_login_flows_with_msc3882_enabled(self) -> None:
+    @override_config({"login_via_existing_session": {"enabled": True}})
+    def test_get_login_flows_with_login_via_existing_enabled(self) -> None:
         """GET /login should return m.login.token without get_login_token true"""
         channel = self.make_request("GET", "/_matrix/client/r0/login")
         self.assertEqual(channel.code, 200, channel.result)
@@ -467,7 +463,7 @@ def test_get_login_flows_with_msc3882_enabled(self) -> None:
         self.assertCountEqual(
             channel.json_body["flows"],
             [
-                {"type": "m.login.token", "org.matrix.msc3882.get_login_token": True},
+                {"type": "m.login.token", "m.get_login_token": True},
                 {"type": "m.login.password"},
                 {"type": "m.login.application_service"},
             ],
diff --git a/tests/rest/client/test_login_token_request.py b/tests/rest/client/test_login_token_request.py
index cdf4134cbed1..c7a848671525 100644
--- a/tests/rest/client/test_login_token_request.py
+++ b/tests/rest/client/test_login_token_request.py
@@ -55,12 +55,12 @@ def test_disabled(self) -> None:
         channel = self.make_request("POST", endpoint, {}, access_token=token)
         self.assertEqual(channel.code, 404)
 
-    @override_config({"experimental_features": {"msc3882_enabled": True}})
+    @override_config({"login_via_existing_session": {"enabled": True}})
     def test_require_auth(self) -> None:
         channel = self.make_request("POST", endpoint, {}, access_token=None)
         self.assertEqual(channel.code, 401)
 
-    @override_config({"experimental_features": {"msc3882_enabled": True}})
+    @override_config({"login_via_existing_session": {"enabled": True}})
     def test_uia_on(self) -> None:
         user_id = self.register_user(self.user, self.password)
         token = self.login(self.user, self.password)
@@ -95,7 +95,7 @@ def test_uia_on(self) -> None:
         self.assertEqual(channel.json_body["user_id"], user_id)
 
     @override_config(
-        {"experimental_features": {"msc3882_enabled": True, "msc3882_ui_auth": False}}
+        {"login_via_existing_session": {"enabled": True, "require_ui_auth": False}}
     )
     def test_uia_off(self) -> None:
         user_id = self.register_user(self.user, self.password)
@@ -117,10 +117,10 @@ def test_uia_off(self) -> None:
 
     @override_config(
         {
-            "experimental_features": {
-                "msc3882_enabled": True,
-                "msc3882_ui_auth": False,
-                "msc3882_token_timeout": "15s",
+            "login_via_existing_session": {
+                "enabled": True,
+                "require_ui_auth": False,
+                "token_timeout": "15s",
             }
         }
     )

From ed9c582eaed1a99d179da2267a7d391b58b8f667 Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Wed, 24 May 2023 10:06:53 -0400
Subject: [PATCH 05/17] Update other unstable identifiers.

---
 synapse/rest/client/login_token_request.py    |  3 +--
 tests/rest/client/test_login_token_request.py | 16 ++++++++--------
 2 files changed, 9 insertions(+), 10 deletions(-)

diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index 44501f3614a7..b6c6d8f2b041 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -48,7 +48,7 @@ class LoginTokenRequestServlet(RestServlet):
     """
 
     PATTERNS = client_patterns(
-        "/org.matrix.msc3882/login/get_token$", releases=[], v1=False, unstable=True
+        "/login/get_token$", releases=["v1"], v1=False, unstable=False
     )
 
     def __init__(self, hs: "HomeServer"):
@@ -77,7 +77,6 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
 
         login_token = await self.auth_handler.create_login_token_for_user_id(
             user_id=requester.user.to_string(),
-            auth_provider_id="org.matrix.msc3882.get_login_token",
             duration_ms=self.token_timeout,
         )
 
diff --git a/tests/rest/client/test_login_token_request.py b/tests/rest/client/test_login_token_request.py
index c7a848671525..d14612cafa84 100644
--- a/tests/rest/client/test_login_token_request.py
+++ b/tests/rest/client/test_login_token_request.py
@@ -22,7 +22,7 @@
 from tests import unittest
 from tests.unittest import override_config
 
-endpoint = "/_matrix/client/unstable/org.matrix.msc3882/login/get_token"
+GET_TOKEN_ENDPOINT = "/_matrix/client/v1/login/get_token"
 
 
 class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
@@ -46,18 +46,18 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
         self.password = "password"
 
     def test_disabled(self) -> None:
-        channel = self.make_request("POST", endpoint, {}, access_token=None)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=None)
         self.assertEqual(channel.code, 404)
 
         self.register_user(self.user, self.password)
         token = self.login(self.user, self.password)
 
-        channel = self.make_request("POST", endpoint, {}, access_token=token)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
         self.assertEqual(channel.code, 404)
 
     @override_config({"login_via_existing_session": {"enabled": True}})
     def test_require_auth(self) -> None:
-        channel = self.make_request("POST", endpoint, {}, access_token=None)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=None)
         self.assertEqual(channel.code, 401)
 
     @override_config({"login_via_existing_session": {"enabled": True}})
@@ -65,7 +65,7 @@ def test_uia_on(self) -> None:
         user_id = self.register_user(self.user, self.password)
         token = self.login(self.user, self.password)
 
-        channel = self.make_request("POST", endpoint, {}, access_token=token)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
         self.assertEqual(channel.code, 401)
         self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
 
@@ -80,7 +80,7 @@ def test_uia_on(self) -> None:
             },
         }
 
-        channel = self.make_request("POST", endpoint, uia, access_token=token)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, uia, access_token=token)
         self.assertEqual(channel.code, 200)
         self.assertEqual(channel.json_body["expires_in_ms"], 300000)
 
@@ -101,7 +101,7 @@ def test_uia_off(self) -> None:
         user_id = self.register_user(self.user, self.password)
         token = self.login(self.user, self.password)
 
-        channel = self.make_request("POST", endpoint, {}, access_token=token)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
         self.assertEqual(channel.code, 200)
         self.assertEqual(channel.json_body["expires_in_ms"], 300000)
 
@@ -128,6 +128,6 @@ def test_expires_in(self) -> None:
         self.register_user(self.user, self.password)
         token = self.login(self.user, self.password)
 
-        channel = self.make_request("POST", endpoint, {}, access_token=token)
+        channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
         self.assertEqual(channel.code, 200)
         self.assertEqual(channel.json_body["expires_in_ms"], 15000)

From ec64ac80896045422a276dfff8c14714da5ac789 Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Wed, 24 May 2023 10:06:33 -0400
Subject: [PATCH 06/17] Add ratelimiting.

---
 synapse/rest/client/login_token_request.py | 17 ++++++++++++++---
 1 file changed, 14 insertions(+), 3 deletions(-)

diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index b6c6d8f2b041..a3c2704493e2 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -15,6 +15,7 @@
 import logging
 from typing import TYPE_CHECKING, Tuple
 
+from synapse.api.ratelimiting import Ratelimiter
 from synapse.http.server import HttpServer
 from synapse.http.servlet import RestServlet, parse_json_object_from_request
 from synapse.http.site import SynapseRequest
@@ -54,13 +55,19 @@ class LoginTokenRequestServlet(RestServlet):
     def __init__(self, hs: "HomeServer"):
         super().__init__()
         self.auth = hs.get_auth()
-        self.store = hs.get_datastores().main
-        self.clock = hs.get_clock()
-        self.server_name = hs.config.server.server_name
+        self._main_store = hs.get_datastores().main
         self.auth_handler = hs.get_auth_handler()
         self.token_timeout = hs.config.auth.login_via_existing_token_timeout
         self._require_ui_auth = hs.config.auth.login_via_existing_require_ui_auth
 
+        # An aggressive ratelimiter.
+        self._ratelimiter = Ratelimiter(
+            store=self._main_store,
+            clock=hs.get_clock(),
+            rate_hz=1 / 60,
+            burst_count=1,
+        )
+
     @interactive_auth_handler
     async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
         requester = await self.auth.get_user_by_req(request)
@@ -75,6 +82,10 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 can_skip_ui_auth=False,  # Don't allow skipping of UI auth
             )
 
+        # Ensure that this endpoint isn't being used too often. (Ensure this is
+        # done *after* UI auth.)
+        await self._ratelimiter.ratelimit(None, requester.user.to_string().lower())
+
         login_token = await self.auth_handler.create_login_token_for_user_id(
             user_id=requester.user.to_string(),
             duration_ms=self.token_timeout,

From 75b0e1a79ff2422f8b0d8acdf9036ef347c013e1 Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Wed, 24 May 2023 10:41:07 -0400
Subject: [PATCH 07/17] Move ui_auth docs under session management.

---
 .../configuration/config_documentation.md     | 43 +++++++++----------
 1 file changed, 21 insertions(+), 22 deletions(-)

diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md
index 93b132b6e4cd..d61492ba6a34 100644
--- a/docs/usage/configuration/config_documentation.md
+++ b/docs/usage/configuration/config_documentation.md
@@ -2570,7 +2570,28 @@ Example configuration:
 ```yaml
 nonrefreshable_access_token_lifetime: 24h
 ```
+---
+### `ui_auth`
+
+The amount of time to allow a user-interactive authentication session to be active.
+
+This defaults to 0, meaning the user is queried for their credentials
+before every action, but this can be overridden to allow a single
+validation to be re-used.  This weakens the protections afforded by
+the user-interactive authentication process, by allowing for multiple
+(and potentially different) operations to use the same validation session.
+
+This is ignored for potentially "dangerous" operations (including
+deactivating an account, modifying an account password, and
+adding a 3PID).
 
+Use the `session_timeout` sub-option here to change the time allowed for credential validation.
+
+Example configuration:
+```yaml
+ui_auth:
+    session_timeout: "15s"
+```
 ---
 ## Metrics
 Config options related to metrics.
@@ -3415,28 +3436,6 @@ password_config:
       require_uppercase: true
 ```
 ---
-### `ui_auth`
-
-The amount of time to allow a user-interactive authentication session to be active.
-
-This defaults to 0, meaning the user is queried for their credentials
-before every action, but this can be overridden to allow a single
-validation to be re-used.  This weakens the protections afforded by
-the user-interactive authentication process, by allowing for multiple
-(and potentially different) operations to use the same validation session.
-
-This is ignored for potentially "dangerous" operations (including
-deactivating an account, modifying an account password, and
-adding a 3PID).
-
-Use the `session_timeout` sub-option here to change the time allowed for credential validation.
-
-Example configuration:
-```yaml
-ui_auth:
-    session_timeout: "15s"
-```
----
 ## Push
 Configuration settings related to push notifications
 

From ac0fb703bdb67bba4c4128543526850a52609fa4 Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Wed, 24 May 2023 10:44:57 -0400
Subject: [PATCH 08/17] Add documentation.

---
 .../configuration/config_documentation.md     | 25 +++++++++++++++++--
 1 file changed, 23 insertions(+), 2 deletions(-)

diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md
index d61492ba6a34..abeed923b768 100644
--- a/docs/usage/configuration/config_documentation.md
+++ b/docs/usage/configuration/config_documentation.md
@@ -2582,8 +2582,8 @@ the user-interactive authentication process, by allowing for multiple
 (and potentially different) operations to use the same validation session.
 
 This is ignored for potentially "dangerous" operations (including
-deactivating an account, modifying an account password, and
-adding a 3PID).
+deactivating an account, modifying an account password, adding a 3PID,
+and minting additional login tokens).
 
 Use the `session_timeout` sub-option here to change the time allowed for credential validation.
 
@@ -2593,6 +2593,27 @@ ui_auth:
     session_timeout: "15s"
 ```
 ---
+### `login_via_existing_session`
+
+Matrix supports the ability of an existing session to mint a login token for
+another client.
+
+Synapse disables this by default as it has security ramifications.
+
+The duration of time the generated token is valid for can be configured with the
+`token_timeout` sub-option.
+
+User-interactive authentication is required when this is enabled unless the
+`require_ui_auth` sub-option is set to `False`.
+
+Example configuration:
+```yaml
+login_via_existing_session:
+    enabled: true
+    require_ui_auth: false
+    token_timeout: "5m"
+```
+---
 ## Metrics
 Config options related to metrics.
 

From c17e16dff89ac6d5f8f2390b71c6f3ed2056bcff Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Thu, 25 May 2023 13:13:04 +0100
Subject: [PATCH 09/17] Reference stable feature in changelog

---
 changelog.d/15388.feature | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/changelog.d/15388.feature b/changelog.d/15388.feature
index cdfd11164786..6cc55cafa2e1 100644
--- a/changelog.d/15388.feature
+++ b/changelog.d/15388.feature
@@ -1 +1 @@
-Experimental implementation of revision 1 of [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882) to allow an existing device/session to generate a login token for use on a new device/session.
\ No newline at end of file
+Stable support for [MSC3882](https://github.com/matrix-org/matrix-spec-proposals/pull/3882) to allow an existing device/session to generate a login token for use on a new device/session.
\ No newline at end of file

From 73e0ca2f85bd113bac1cee4ce9999c00ff3b476b Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Thu, 25 May 2023 13:28:38 +0100
Subject: [PATCH 10/17] Reinstate support for unstable revision 0 of MSC3882

---
 synapse/rest/client/login_token_request.py | 12 +++++++++---
 synapse/rest/client/versions.py            |  2 ++
 2 files changed, 11 insertions(+), 3 deletions(-)

diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index a3c2704493e2..8cfc2fc48990 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -48,9 +48,13 @@ class LoginTokenRequestServlet(RestServlet):
     }
     """
 
-    PATTERNS = client_patterns(
-        "/login/get_token$", releases=["v1"], v1=False, unstable=False
-    )
+    PATTERNS = [
+        *client_patterns("/login/get_token$", releases=["v1"], v1=False, unstable=False),
+        # TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
+        *client_patterns(
+            "/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True
+        ),
+    ]
 
     def __init__(self, hs: "HomeServer"):
         super().__init__()
@@ -95,6 +99,8 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             200,
             {
                 "login_token": login_token,
+                # TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
+                "expires_in": self.token_timeout // 1000,
                 "expires_in_ms": self.token_timeout,
             },
         )
diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index 1eb11081a07f..c9f062ae7957 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -115,6 +115,8 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
                     "fi.mau.msc2659.stable": True,  # TODO: remove when "v1.7" is added above
                     # Adds support for remotely enabling/disabling pushers, as per MSC3881
                     "org.matrix.msc3881": self.config.experimental.msc3881_enabled,
+                    # TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
+                    "org.matrix.msc3882": self.config.auth.login_via_existing_enabled,
                     # Adds support for filtering /messages by event relation.
                     "org.matrix.msc3874": self.config.experimental.msc3874_enabled,
                     # Adds support for simple HTTP rendezvous as per MSC3886

From 1d41bf1f78bf93a973c0e65b7262701b6f52207a Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Thu, 25 May 2023 13:31:04 +0100
Subject: [PATCH 11/17] Cleaner diff

---
 synapse/rest/client/versions.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py
index c9f062ae7957..547bf34df15e 100644
--- a/synapse/rest/client/versions.py
+++ b/synapse/rest/client/versions.py
@@ -113,10 +113,10 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
                     "fi.mau.msc2815": self.config.experimental.msc2815_enabled,
                     # Adds a ping endpoint for appservices to check HS->AS connection
                     "fi.mau.msc2659.stable": True,  # TODO: remove when "v1.7" is added above
-                    # Adds support for remotely enabling/disabling pushers, as per MSC3881
-                    "org.matrix.msc3881": self.config.experimental.msc3881_enabled,
                     # TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
                     "org.matrix.msc3882": self.config.auth.login_via_existing_enabled,
+                    # Adds support for remotely enabling/disabling pushers, as per MSC3881
+                    "org.matrix.msc3881": self.config.experimental.msc3881_enabled,
                     # Adds support for filtering /messages by event relation.
                     "org.matrix.msc3874": self.config.experimental.msc3874_enabled,
                     # Adds support for simple HTTP rendezvous as per MSC3886

From 17f73cd70251832334b9ebf532c790c19b7c36af Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Thu, 25 May 2023 13:39:06 +0100
Subject: [PATCH 12/17] Add tests for unstable support whilst still relied on

---
 tests/rest/client/test_login_token_request.py | 28 ++++++++++++++++++-
 1 file changed, 27 insertions(+), 1 deletion(-)

diff --git a/tests/rest/client/test_login_token_request.py b/tests/rest/client/test_login_token_request.py
index d14612cafa84..d2344aa9ab4a 100644
--- a/tests/rest/client/test_login_token_request.py
+++ b/tests/rest/client/test_login_token_request.py
@@ -15,7 +15,7 @@
 from twisted.test.proto_helpers import MemoryReactor
 
 from synapse.rest import admin
-from synapse.rest.client import login, login_token_request
+from synapse.rest.client import login, login_token_request, versions
 from synapse.server import HomeServer
 from synapse.util import Clock
 
@@ -30,6 +30,7 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
         login.register_servlets,
         admin.register_servlets,
         login_token_request.register_servlets,
+        versions.register_servlets, # TODO: remove once unstable revision 0 support is removed
     ]
 
     def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
@@ -131,3 +132,28 @@ def test_expires_in(self) -> None:
         channel = self.make_request("POST", GET_TOKEN_ENDPOINT, {}, access_token=token)
         self.assertEqual(channel.code, 200)
         self.assertEqual(channel.json_body["expires_in_ms"], 15000)
+
+    @override_config(
+        {
+            "login_via_existing_session": {
+                "enabled": True,
+                "require_ui_auth": False,
+                "token_timeout": "15s",
+            }
+        }
+    )
+    def test_unstable_support(self) -> None:
+        # TODO: remove support for unstable MSC3882 is no longer needed
+
+        # check feature is advertised in versions response:
+        channel = self.make_request("GET", "/_matrix/client/versions", {}, access_token=None)
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["unstable_features"]["org.matrix.msc3882"], True)
+
+        self.register_user(self.user, self.password)
+        token = self.login(self.user, self.password)
+
+        # check feature is available via the unstable endpoint and returns an expires_in value in seconds
+        channel = self.make_request("POST", "/_matrix/client/unstable/org.matrix.msc3882/login/token", {}, access_token=token)
+        self.assertEqual(channel.code, 200)
+        self.assertEqual(channel.json_body["expires_in"], 15)

From 77a6ee6bd8f521fa9113f537e68e9d42a6c07ba6 Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Thu, 25 May 2023 13:40:11 +0100
Subject: [PATCH 13/17] Lint

---
 synapse/rest/client/login_token_request.py    |  4 +++-
 tests/rest/client/test_login_token_request.py | 17 +++++++++++++----
 2 files changed, 16 insertions(+), 5 deletions(-)

diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index 8cfc2fc48990..282d2d308f3f 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -49,7 +49,9 @@ class LoginTokenRequestServlet(RestServlet):
     """
 
     PATTERNS = [
-        *client_patterns("/login/get_token$", releases=["v1"], v1=False, unstable=False),
+        *client_patterns(
+            "/login/get_token$", releases=["v1"], v1=False, unstable=False
+        ),
         # TODO: this is no longer needed once unstable MSC3882 does not need to be supported:
         *client_patterns(
             "/org.matrix.msc3882/login/token$", releases=[], v1=False, unstable=True
diff --git a/tests/rest/client/test_login_token_request.py b/tests/rest/client/test_login_token_request.py
index d2344aa9ab4a..f05e619aa86d 100644
--- a/tests/rest/client/test_login_token_request.py
+++ b/tests/rest/client/test_login_token_request.py
@@ -30,7 +30,7 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
         login.register_servlets,
         admin.register_servlets,
         login_token_request.register_servlets,
-        versions.register_servlets, # TODO: remove once unstable revision 0 support is removed
+        versions.register_servlets,  # TODO: remove once unstable revision 0 support is removed
     ]
 
     def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
@@ -146,14 +146,23 @@ def test_unstable_support(self) -> None:
         # TODO: remove support for unstable MSC3882 is no longer needed
 
         # check feature is advertised in versions response:
-        channel = self.make_request("GET", "/_matrix/client/versions", {}, access_token=None)
+        channel = self.make_request(
+            "GET", "/_matrix/client/versions", {}, access_token=None
+        )
         self.assertEqual(channel.code, 200)
-        self.assertEqual(channel.json_body["unstable_features"]["org.matrix.msc3882"], True)
+        self.assertEqual(
+            channel.json_body["unstable_features"]["org.matrix.msc3882"], True
+        )
 
         self.register_user(self.user, self.password)
         token = self.login(self.user, self.password)
 
         # check feature is available via the unstable endpoint and returns an expires_in value in seconds
-        channel = self.make_request("POST", "/_matrix/client/unstable/org.matrix.msc3882/login/token", {}, access_token=token)
+        channel = self.make_request(
+            "POST",
+            "/_matrix/client/unstable/org.matrix.msc3882/login/token",
+            {},
+            access_token=token,
+        )
         self.assertEqual(channel.code, 200)
         self.assertEqual(channel.json_body["expires_in"], 15)

From 210c22dabeb8dd3930470a9a9da238ef8750e694 Mon Sep 17 00:00:00 2001
From: Hugh Nimmo-Smith <hughns@element.io>
Date: Tue, 30 May 2023 17:36:51 +0100
Subject: [PATCH 14/17] Fix login flow parameter name

It is `get_login_flow` not `m.get_login_flow` ref https://spec.matrix.org/v1.7/client-server-api/#get_matrixclientv3login
---
 synapse/rest/client/login.py    | 2 +-
 tests/rest/client/test_login.py | 2 +-
 2 files changed, 2 insertions(+), 2 deletions(-)

diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index fed61abecbd5..7db5ce899422 100644
--- a/synapse/rest/client/login.py
+++ b/synapse/rest/client/login.py
@@ -166,7 +166,7 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
             # If the login token flow is enabled advertise the get_login_token flag.
             if self._get_login_token_enabled:
-                tokenTypeFlow["m.get_login_token"] = True
+                tokenTypeFlow["get_login_token"] = True
             flows.append(tokenTypeFlow)
 
         flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types())
diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py
index 8f518761700c..ef292dff3835 100644
--- a/tests/rest/client/test_login.py
+++ b/tests/rest/client/test_login.py
@@ -463,7 +463,7 @@ def test_get_login_flows_with_login_via_existing_enabled(self) -> None:
         self.assertCountEqual(
             channel.json_body["flows"],
             [
-                {"type": "m.login.token", "m.get_login_token": True},
+                {"type": "m.login.token", "get_login_token": True},
                 {"type": "m.login.password"},
                 {"type": "m.login.application_service"},
             ],

From b9ab999750cf93db6a31a55adafaefd5082b2e4e Mon Sep 17 00:00:00 2001
From: Patrick Cloke <clokep@users.noreply.github.com>
Date: Tue, 30 May 2023 13:06:49 -0400
Subject: [PATCH 15/17] Fixes for develop

Fixes for compatibility with #15582.
---
 synapse/config/experimental.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py
index 29c57f77704b..a9e002cf08f8 100644
--- a/synapse/config/experimental.py
+++ b/synapse/config/experimental.py
@@ -192,10 +192,10 @@ def check_config_conflicts(self, root: RootConfig) -> None:
                 ("captcha", "enable_registration_captcha"),
             )
 
-        if root.experimental.msc3882_enabled:
+        if root.auth.login_via_existing_enabled:
             raise ConfigError(
-                "MSC3882 cannot be enabled when OAuth delegation is enabled",
-                ("experimental_features", "msc3882_enabled"),
+                "Login via existing session cannot be enabled when OAuth delegation is enabled",
+                ("login_via_existing_session", "enabled"),
             )
 
         if root.registration.refresh_token_lifetime:

From 88c70598a07215e82b8febea3e46ed07987417fd Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Tue, 30 May 2023 14:02:51 -0400
Subject: [PATCH 16/17] Fix-up test.

---
 tests/config/test_oauth_delegation.py | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/tests/config/test_oauth_delegation.py b/tests/config/test_oauth_delegation.py
index 2ead721b00fc..f57c813a581e 100644
--- a/tests/config/test_oauth_delegation.py
+++ b/tests/config/test_oauth_delegation.py
@@ -228,8 +228,8 @@ def test_jwt_auth_cannot_be_enabled(self) -> None:
         with self.assertRaises(ConfigError):
             self.parse_config()
 
-    def test_msc3882_auth_cannot_be_enabled(self) -> None:
-        self.config_dict["experimental_features"]["msc3882_enabled"] = True
+    def test_login_via_existing_session_cannot_be_enabled(self) -> None:
+        self.config_dict["login_via_existing_session"] = {"enabled": True}
         with self.assertRaises(ConfigError):
             self.parse_config()
 

From 2bd6f72b3c01ceba012ac1c006c6d93dc3bed03b Mon Sep 17 00:00:00 2001
From: Patrick Cloke <patrickc@matrix.org>
Date: Thu, 1 Jun 2023 07:56:13 -0400
Subject: [PATCH 17/17] Additional comments and clarifications.

---
 docs/usage/configuration/config_documentation.md | 3 ++-
 synapse/rest/client/login.py                     | 8 ++++++++
 synapse/rest/client/login_token_request.py       | 5 ++++-
 tests/rest/client/test_login.py                  | 4 ++--
 4 files changed, 16 insertions(+), 4 deletions(-)

diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md
index d9a3537ed9f0..0cf6e075ff11 100644
--- a/docs/usage/configuration/config_documentation.md
+++ b/docs/usage/configuration/config_documentation.md
@@ -2598,7 +2598,8 @@ ui_auth:
 Matrix supports the ability of an existing session to mint a login token for
 another client.
 
-Synapse disables this by default as it has security ramifications.
+Synapse disables this by default as it has security ramifications -- a malicious
+client could use the mechanism to spawn more than one session.
 
 The duration of time the generated token is valid for can be configured with the
 `token_timeout` sub-option.
diff --git a/synapse/rest/client/login.py b/synapse/rest/client/login.py
index 37a065932cfc..6493b00bb803 100644
--- a/synapse/rest/client/login.py
+++ b/synapse/rest/client/login.py
@@ -162,6 +162,14 @@ def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
             # SSO requires a login token to be generated, so we need to advertise that flow
             support_login_token_flow = True
 
+        # While it's valid for us to advertise this login type generally,
+        # synapse currently only gives out these tokens as part of the
+        # SSO login flow or as part of login via an existing session.
+        #
+        # Generally we don't want to advertise login flows that clients
+        # don't know how to implement, since they (currently) will always
+        # fall back to the fallback API if they don't understand one of the
+        # login flow types returned.
         if support_login_token_flow:
             tokenTypeFlow: Dict[str, Any] = {"type": LoginRestServlet.TOKEN_TYPE}
             # If the login token flow is enabled advertise the get_login_token flag.
diff --git a/synapse/rest/client/login_token_request.py b/synapse/rest/client/login_token_request.py
index 282d2d308f3f..b1629f94a5f8 100644
--- a/synapse/rest/client/login_token_request.py
+++ b/synapse/rest/client/login_token_request.py
@@ -66,7 +66,10 @@ def __init__(self, hs: "HomeServer"):
         self.token_timeout = hs.config.auth.login_via_existing_token_timeout
         self._require_ui_auth = hs.config.auth.login_via_existing_require_ui_auth
 
-        # An aggressive ratelimiter.
+        # Ratelimit aggressively to a maxmimum of 1 request per minute.
+        #
+        # This endpoint can be used to spawn additional sessions and could be
+        # abused by a malicious client to create many sessions.
         self._ratelimiter = Ratelimiter(
             store=self._main_store,
             clock=hs.get_clock(),
diff --git a/tests/rest/client/test_login.py b/tests/rest/client/test_login.py
index ef292dff3835..f3c3bc69a97a 100644
--- a/tests/rest/client/test_login.py
+++ b/tests/rest/client/test_login.py
@@ -447,7 +447,7 @@ def test_require_approval(self) -> None:
         )
 
     def test_get_login_flows_with_login_via_existing_disabled(self) -> None:
-        """GET /login should return m.login.token without get_login_token true"""
+        """GET /login should return m.login.token without get_login_token"""
         channel = self.make_request("GET", "/_matrix/client/r0/login")
         self.assertEqual(channel.code, 200, channel.result)
 
@@ -456,7 +456,7 @@ def test_get_login_flows_with_login_via_existing_disabled(self) -> None:
 
     @override_config({"login_via_existing_session": {"enabled": True}})
     def test_get_login_flows_with_login_via_existing_enabled(self) -> None:
-        """GET /login should return m.login.token without get_login_token true"""
+        """GET /login should return m.login.token with get_login_token true"""
         channel = self.make_request("GET", "/_matrix/client/r0/login")
         self.assertEqual(channel.code, 200, channel.result)