Implement stable support for MSC3882 to allow an existing device/session to generate a login token for use on a new device/session (#15388)
Implements stable support for MSC3882; this involves updating Synapse's support to match the MSC / the spec says. Continue to support the unstable version to allow clients to transition.
This commit is contained in:
parent
0b5f64ff09
commit
d1693f0362
|
@ -0,0 +1 @@
|
|||
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.
|
|
@ -2570,7 +2570,50 @@ 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, adding a 3PID,
|
||||
and minting additional login tokens).
|
||||
|
||||
Use the `session_timeout` sub-option here to change the time allowed for credential validation.
|
||||
|
||||
Example configuration:
|
||||
```yaml
|
||||
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 -- 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.
|
||||
|
||||
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.
|
||||
|
@ -3415,28 +3458,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
|
||||
|
||||
|
|
|
@ -60,3 +60,13 @@ class AuthConfig(Config):
|
|||
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")
|
||||
)
|
||||
|
|
|
@ -192,10 +192,10 @@ class MSC3861:
|
|||
("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:
|
||||
|
@ -319,13 +319,6 @@ class ExperimentalConfig(Config):
|
|||
# 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)
|
||||
|
||||
|
|
|
@ -65,6 +65,9 @@ class CapabilitiesRestServlet(RestServlet):
|
|||
"m.3pid_changes": {
|
||||
"enabled": self.config.registration.enable_3pid_changes
|
||||
},
|
||||
"m.get_login_token": {
|
||||
"enabled": self.config.auth.login_via_existing_enabled,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -104,6 +104,9 @@ class LoginRestServlet(RestServlet):
|
|||
and hs.config.experimental.msc3866.require_approval_for_new_accounts
|
||||
)
|
||||
|
||||
# Whether get login token is enabled.
|
||||
self._get_login_token_enabled = hs.config.auth.login_via_existing_enabled
|
||||
|
||||
self.auth = hs.get_auth()
|
||||
|
||||
self.clock = hs.get_clock()
|
||||
|
@ -142,6 +145,9 @@ class LoginRestServlet(RestServlet):
|
|||
# to SSO.
|
||||
flows.append({"type": LoginRestServlet.CAS_TYPE})
|
||||
|
||||
# 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(
|
||||
{
|
||||
|
@ -153,14 +159,23 @@ class LoginRestServlet(RestServlet):
|
|||
}
|
||||
)
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
flows.append({"type": LoginRestServlet.TOKEN_TYPE})
|
||||
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.
|
||||
if self._get_login_token_enabled:
|
||||
tokenTypeFlow["get_login_token"] = True
|
||||
flows.append(tokenTypeFlow)
|
||||
|
||||
flows.extend({"type": t} for t in self.auth_handler.get_supported_login_types())
|
||||
|
||||
|
|
|
@ -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
|
||||
|
@ -33,7 +34,7 @@ class LoginTokenRequestServlet(RestServlet):
|
|||
|
||||
Request:
|
||||
|
||||
POST /login/token HTTP/1.1
|
||||
POST /login/get_token HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
|
@ -43,30 +44,45 @@ class LoginTokenRequestServlet(RestServlet):
|
|||
HTTP/1.1 200 OK
|
||||
{
|
||||
"login_token": "ABDEFGH",
|
||||
"expires_in": 3600,
|
||||
"expires_in_ms": 3600000,
|
||||
}
|
||||
"""
|
||||
|
||||
PATTERNS = client_patterns(
|
||||
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__()
|
||||
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.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
|
||||
|
||||
# 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(),
|
||||
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)
|
||||
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,
|
||||
|
@ -75,9 +91,12 @@ class LoginTokenRequestServlet(RestServlet):
|
|||
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(),
|
||||
auth_provider_id="org.matrix.msc3882.login_token_request",
|
||||
duration_ms=self.token_timeout,
|
||||
)
|
||||
|
||||
|
@ -85,11 +104,13 @@ class LoginTokenRequestServlet(RestServlet):
|
|||
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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
|
|
@ -113,8 +113,8 @@ class VersionsRestServlet(RestServlet):
|
|||
"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 login token requests as per MSC3882
|
||||
"org.matrix.msc3882": self.config.experimental.msc3882_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.
|
||||
|
|
|
@ -228,8 +228,8 @@ class MSC3861OAuthDelegation(TestCase):
|
|||
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()
|
||||
|
||||
|
|
|
@ -186,3 +186,31 @@ class CapabilitiesTestCase(unittest.HomeserverTestCase):
|
|||
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_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
|
||||
)
|
||||
)
|
||||
|
||||
channel = self.make_request("GET", self.url, access_token=access_token)
|
||||
capabilities = channel.json_body["capabilities"]
|
||||
|
||||
self.assertEqual(channel.code, HTTPStatus.OK)
|
||||
self.assertFalse(capabilities["m.get_login_token"]["enabled"])
|
||||
|
||||
@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
|
||||
)
|
||||
)
|
||||
|
||||
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["m.get_login_token"]["enabled"])
|
||||
|
|
|
@ -446,6 +446,29 @@ class LoginRestServletTestCase(unittest.HomeserverTestCase):
|
|||
ApprovalNoticeMedium.NONE, channel.json_body["approval_notice_medium"]
|
||||
)
|
||||
|
||||
def test_get_login_flows_with_login_via_existing_disabled(self) -> None:
|
||||
"""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)
|
||||
|
||||
flows = {flow["type"]: flow for flow in channel.json_body["flows"]}
|
||||
self.assertNotIn("m.login.token", flows)
|
||||
|
||||
@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 with get_login_token true"""
|
||||
channel = self.make_request("GET", "/_matrix/client/r0/login")
|
||||
self.assertEqual(channel.code, 200, channel.result)
|
||||
|
||||
self.assertCountEqual(
|
||||
channel.json_body["flows"],
|
||||
[
|
||||
{"type": "m.login.token", "get_login_token": True},
|
||||
{"type": "m.login.password"},
|
||||
{"type": "m.login.application_service"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@skip_unless(has_saml2 and HAS_OIDC, "Requires SAML2 and OIDC")
|
||||
class MultiSSOTestCase(unittest.HomeserverTestCase):
|
||||
|
|
|
@ -15,14 +15,14 @@
|
|||
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
|
||||
|
||||
from tests import unittest
|
||||
from tests.unittest import override_config
|
||||
|
||||
endpoint = "/_matrix/client/unstable/org.matrix.msc3882/login/token"
|
||||
GET_TOKEN_ENDPOINT = "/_matrix/client/v1/login/get_token"
|
||||
|
||||
|
||||
class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
|
||||
|
@ -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:
|
||||
|
@ -46,26 +47,26 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
|
|||
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({"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)
|
||||
channel = self.make_request("POST", GET_TOKEN_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)
|
||||
|
||||
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,9 +81,9 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
|
|||
},
|
||||
}
|
||||
|
||||
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"], 300)
|
||||
self.assertEqual(channel.json_body["expires_in_ms"], 300000)
|
||||
|
||||
login_token = channel.json_body["login_token"]
|
||||
|
||||
|
@ -95,15 +96,15 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
|
|||
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)
|
||||
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"], 300)
|
||||
self.assertEqual(channel.json_body["expires_in_ms"], 300000)
|
||||
|
||||
login_token = channel.json_body["login_token"]
|
||||
|
||||
|
@ -117,10 +118,10 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
|
|||
|
||||
@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",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -128,6 +129,40 @@ class LoginTokenRequestServletTestCase(unittest.HomeserverTestCase):
|
|||
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)
|
||||
|
||||
@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)
|
||||
|
|
Loading…
Reference in New Issue