Support for MSC4190: device management for application services (#17705)

This is an implementation of MSC4190, which allows appservices to manage
their user's devices without /login & /logout.

---------

Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
This commit is contained in:
Quentin Gliech 2024-12-04 12:04:49 +01:00 committed by GitHub
parent abf44ad324
commit 23b626f2e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 351 additions and 33 deletions

View file

@ -0,0 +1 @@
Support for [MSC4190](https://github.com/matrix-org/matrix-spec-proposals/pull/4190): device management for Application Services.

View file

@ -87,6 +87,7 @@ class ApplicationService:
ip_range_whitelist: Optional[IPSet] = None, ip_range_whitelist: Optional[IPSet] = None,
supports_ephemeral: bool = False, supports_ephemeral: bool = False,
msc3202_transaction_extensions: bool = False, msc3202_transaction_extensions: bool = False,
msc4190_device_management: bool = False,
): ):
self.token = token self.token = token
self.url = ( self.url = (
@ -100,6 +101,7 @@ class ApplicationService:
self.ip_range_whitelist = ip_range_whitelist self.ip_range_whitelist = ip_range_whitelist
self.supports_ephemeral = supports_ephemeral self.supports_ephemeral = supports_ephemeral
self.msc3202_transaction_extensions = msc3202_transaction_extensions self.msc3202_transaction_extensions = msc3202_transaction_extensions
self.msc4190_device_management = msc4190_device_management
if "|" in self.id: if "|" in self.id:
raise Exception("application service ID cannot contain '|' character") raise Exception("application service ID cannot contain '|' character")

View file

@ -183,6 +183,18 @@ def _load_appservice(
"The `org.matrix.msc3202` option should be true or false if specified." "The `org.matrix.msc3202` option should be true or false if specified."
) )
# Opt-in flag for the MSC4190 behaviours.
# When enabled, the following C-S API endpoints change for appservices:
# - POST /register does not return an access token
# - PUT /devices/{device_id} creates a new device if one does not exist
# - DELETE /devices/{device_id} no longer requires UIA
# - POST /delete_devices/{device_id} no longer requires UIA
msc4190_enabled = as_info.get("io.element.msc4190", False)
if not isinstance(msc4190_enabled, bool):
raise ValueError(
"The `io.element.msc4190` option should be true or false if specified."
)
return ApplicationService( return ApplicationService(
token=as_info["as_token"], token=as_info["as_token"],
url=as_info["url"], url=as_info["url"],
@ -195,4 +207,5 @@ def _load_appservice(
ip_range_whitelist=ip_range_whitelist, ip_range_whitelist=ip_range_whitelist,
supports_ephemeral=supports_ephemeral, supports_ephemeral=supports_ephemeral,
msc3202_transaction_extensions=msc3202_transaction_extensions, msc3202_transaction_extensions=msc3202_transaction_extensions,
msc4190_device_management=msc4190_enabled,
) )

View file

@ -729,6 +729,40 @@ class DeviceHandler(DeviceWorkerHandler):
await self.notify_device_update(user_id, device_ids) await self.notify_device_update(user_id, device_ids)
async def upsert_device(
self, user_id: str, device_id: str, display_name: Optional[str] = None
) -> bool:
"""Create or update a device
Args:
user_id: The user to update devices of.
device_id: The device to update.
display_name: The new display name for this device.
Returns:
True if the device was created, False if it was updated.
"""
# Reject a new displayname which is too long.
self._check_device_name_length(display_name)
created = await self.store.store_device(
user_id,
device_id,
initial_device_display_name=display_name,
)
if not created:
await self.store.update_device(
user_id,
device_id,
new_display_name=display_name,
)
await self.notify_device_update(user_id, [device_id])
return created
async def update_device(self, user_id: str, device_id: str, content: dict) -> None: async def update_device(self, user_id: str, device_id: str, content: dict) -> None:
"""Update the given device """Update the given device

View file

@ -630,7 +630,9 @@ class RegistrationHandler:
""" """
await self._auto_join_rooms(user_id) await self._auto_join_rooms(user_id)
async def appservice_register(self, user_localpart: str, as_token: str) -> str: async def appservice_register(
self, user_localpart: str, as_token: str
) -> Tuple[str, ApplicationService]:
user = UserID(user_localpart, self.hs.hostname) user = UserID(user_localpart, self.hs.hostname)
user_id = user.to_string() user_id = user.to_string()
service = self.store.get_app_service_by_token(as_token) service = self.store.get_app_service_by_token(as_token)
@ -653,7 +655,7 @@ class RegistrationHandler:
appservice_id=service_id, appservice_id=service_id,
create_profile_with_displayname=user.localpart, create_profile_with_displayname=user.localpart,
) )
return user_id return (user_id, service)
def check_user_id_not_appservice_exclusive( def check_user_id_not_appservice_exclusive(
self, user_id: str, allowed_appservice: Optional[ApplicationService] = None self, user_id: str, allowed_appservice: Optional[ApplicationService] = None

View file

@ -114,6 +114,10 @@ class DeleteDevicesRestServlet(RestServlet):
else: else:
raise e raise e
if requester.app_service and requester.app_service.msc4190_device_management:
# MSC4190 can skip UIA for this endpoint
pass
else:
await self.auth_handler.validate_user_via_ui_auth( await self.auth_handler.validate_user_via_ui_auth(
requester, requester,
request, request,
@ -175,9 +179,6 @@ class DeviceRestServlet(RestServlet):
async def on_DELETE( async def on_DELETE(
self, request: SynapseRequest, device_id: str self, request: SynapseRequest, device_id: str
) -> Tuple[int, JsonDict]: ) -> Tuple[int, JsonDict]:
if self._msc3861_oauth_delegation_enabled:
raise UnrecognizedRequestError(code=404)
requester = await self.auth.get_user_by_req(request) requester = await self.auth.get_user_by_req(request)
try: try:
@ -192,6 +193,15 @@ class DeviceRestServlet(RestServlet):
else: else:
raise raise
if requester.app_service and requester.app_service.msc4190_device_management:
# MSC4190 allows appservices to delete devices through this endpoint without UIA
# It's also allowed with MSC3861 enabled
pass
else:
if self._msc3861_oauth_delegation_enabled:
raise UnrecognizedRequestError(code=404)
await self.auth_handler.validate_user_via_ui_auth( await self.auth_handler.validate_user_via_ui_auth(
requester, requester,
request, request,
@ -216,6 +226,16 @@ class DeviceRestServlet(RestServlet):
requester = await self.auth.get_user_by_req(request, allow_guest=True) requester = await self.auth.get_user_by_req(request, allow_guest=True)
body = parse_and_validate_json_object_from_request(request, self.PutBody) body = parse_and_validate_json_object_from_request(request, self.PutBody)
# MSC4190 allows appservices to create devices through this endpoint
if requester.app_service and requester.app_service.msc4190_device_management:
created = await self.device_handler.upsert_device(
user_id=requester.user.to_string(),
device_id=device_id,
display_name=body.display_name,
)
return 201 if created else 200, {}
await self.device_handler.update_device( await self.device_handler.update_device(
requester.user.to_string(), device_id, body.dict() requester.user.to_string(), device_id, body.dict()
) )

View file

@ -771,9 +771,12 @@ class RegisterRestServlet(RestServlet):
body: JsonDict, body: JsonDict,
should_issue_refresh_token: bool = False, should_issue_refresh_token: bool = False,
) -> JsonDict: ) -> JsonDict:
user_id = await self.registration_handler.appservice_register( user_id, appservice = await self.registration_handler.appservice_register(
username, as_token username, as_token
) )
if appservice.msc4190_device_management:
body["inhibit_login"] = True
return await self._create_registration_details( return await self._create_registration_details(
user_id, user_id,
body, body,
@ -937,7 +940,7 @@ class RegisterAppServiceOnlyRestServlet(RestServlet):
as_token = self.auth.get_access_token_from_request(request) as_token = self.auth.get_access_token_from_request(request)
user_id = await self.registration_handler.appservice_register( user_id, _ = await self.registration_handler.appservice_register(
desired_username, as_token desired_username, as_token
) )
return 200, {"user_id": user_id} return 200, {"user_id": user_id}

View file

@ -1165,12 +1165,23 @@ class ApplicationServicesHandlerOtkCountsTestCase(unittest.HomeserverTestCase):
self.hs.get_datastores().main.services_cache = [self._service] self.hs.get_datastores().main.services_cache = [self._service]
# Register some appservice users # Register some appservice users
self._sender_user, self._sender_device = self.register_appservice_user( user_id, device_id = self.register_appservice_user(
"as.sender", self._service_token "as.sender", self._service_token
) )
self._namespaced_user, self._namespaced_device = self.register_appservice_user( # With MSC4190 enabled, there will not be a device created
# during AS registration. However MSC4190 is not enabled
# in this test. It may become the default behaviour in the
# future, in which case this test will need to be updated.
assert device_id is not None
self._sender_user = user_id
self._sender_device = device_id
user_id, device_id = self.register_appservice_user(
"_as_user1", self._service_token "_as_user1", self._service_token
) )
assert device_id is not None
self._namespaced_user = user_id
self._namespaced_device = device_id
# Register a real user as well. # Register a real user as well.
self._real_user = self.register_user("real.user", "meow") self._real_user = self.register_user("real.user", "meow")

View file

@ -560,9 +560,15 @@ class MSC3861OAuthDelegation(HomeserverTestCase):
self.assertEqual(channel.code, 401, channel.json_body) self.assertEqual(channel.code, 401, channel.json_body)
def expect_unrecognized( def expect_unrecognized(
self, method: str, path: str, content: Union[bytes, str, JsonDict] = "" self,
method: str,
path: str,
content: Union[bytes, str, JsonDict] = "",
auth: bool = False,
) -> None: ) -> None:
channel = self.make_request(method, path, content) channel = self.make_request(
method, path, content, access_token="token" if auth else None
)
self.assertEqual(channel.code, 404, channel.json_body) self.assertEqual(channel.code, 404, channel.json_body)
self.assertEqual( self.assertEqual(
@ -648,8 +654,25 @@ class MSC3861OAuthDelegation(HomeserverTestCase):
def test_device_management_endpoints_removed(self) -> None: def test_device_management_endpoints_removed(self) -> None:
"""Test that device management endpoints that were removed in MSC2964 are no longer available.""" """Test that device management endpoints that were removed in MSC2964 are no longer available."""
self.expect_unrecognized("POST", "/_matrix/client/v3/delete_devices")
self.expect_unrecognized("DELETE", "/_matrix/client/v3/devices/{DEVICE}") # Because we still support those endpoints with ASes, it checks the
# access token before returning 404
self.http_client.request = AsyncMock(
return_value=FakeResponse.json(
code=200,
payload={
"active": True,
"sub": SUBJECT,
"scope": " ".join([MATRIX_USER_SCOPE, MATRIX_DEVICE_SCOPE]),
"username": USERNAME,
},
)
)
self.expect_unrecognized("POST", "/_matrix/client/v3/delete_devices", auth=True)
self.expect_unrecognized(
"DELETE", "/_matrix/client/v3/devices/{DEVICE}", auth=True
)
def test_openid_endpoints_removed(self) -> None: def test_openid_endpoints_removed(self) -> None:
"""Test that OpenID id_token endpoints that were removed in MSC2964 are no longer available.""" """Test that OpenID id_token endpoints that were removed in MSC2964 are no longer available."""

View file

@ -24,6 +24,7 @@ from twisted.internet.defer import ensureDeferred
from twisted.test.proto_helpers import MemoryReactor from twisted.test.proto_helpers import MemoryReactor
from synapse.api.errors import NotFoundError from synapse.api.errors import NotFoundError
from synapse.appservice import ApplicationService
from synapse.rest import admin, devices, sync from synapse.rest import admin, devices, sync
from synapse.rest.client import keys, login, register from synapse.rest.client import keys, login, register
from synapse.server import HomeServer from synapse.server import HomeServer
@ -455,3 +456,183 @@ class DehydratedDeviceTestCase(unittest.HomeserverTestCase):
token, token,
) )
self.assertEqual(channel.json_body["device_keys"], {"@mikey:test": {}}) self.assertEqual(channel.json_body["device_keys"], {"@mikey:test": {}})
class MSC4190AppserviceDevicesTestCase(unittest.HomeserverTestCase):
servlets = [
register.register_servlets,
devices.register_servlets,
]
def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
self.hs = self.setup_test_homeserver()
# This application service uses the new MSC4190 behaviours
self.msc4190_service = ApplicationService(
id="msc4190",
token="some_token",
hs_token="some_token",
sender="@as:example.com",
namespaces={
ApplicationService.NS_USERS: [{"regex": "@.*", "exclusive": False}]
},
msc4190_device_management=True,
)
# This application service doesn't use the new MSC4190 behaviours
self.pre_msc_service = ApplicationService(
id="regular",
token="other_token",
hs_token="other_token",
sender="@as2:example.com",
namespaces={
ApplicationService.NS_USERS: [{"regex": "@.*", "exclusive": False}]
},
msc4190_device_management=False,
)
self.hs.get_datastores().main.services_cache.append(self.msc4190_service)
self.hs.get_datastores().main.services_cache.append(self.pre_msc_service)
return self.hs
def test_PUT_device(self) -> None:
self.register_appservice_user("alice", self.msc4190_service.token)
self.register_appservice_user("bob", self.pre_msc_service.token)
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(channel.json_body, {"devices": []})
channel = self.make_request(
"PUT",
"/_matrix/client/v3/devices/AABBCCDD?user_id=@alice:test",
content={"display_name": "Alice's device"},
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 201, channel.json_body)
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(len(channel.json_body["devices"]), 1)
self.assertEqual(channel.json_body["devices"][0]["device_id"], "AABBCCDD")
# Doing a second time should return a 200 instead of a 201
channel = self.make_request(
"PUT",
"/_matrix/client/v3/devices/AABBCCDD?user_id=@alice:test",
content={"display_name": "Alice's device"},
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
# On the regular service, that API should not allow for the
# creation of new devices.
channel = self.make_request(
"PUT",
"/_matrix/client/v3/devices/AABBCCDD?user_id=@bob:test",
content={"display_name": "Bob's device"},
access_token=self.pre_msc_service.token,
)
self.assertEqual(channel.code, 404, channel.json_body)
def test_DELETE_device(self) -> None:
self.register_appservice_user("alice", self.msc4190_service.token)
# There should be no device
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(channel.json_body, {"devices": []})
# Create a device
channel = self.make_request(
"PUT",
"/_matrix/client/v3/devices/AABBCCDD?user_id=@alice:test",
content={},
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 201, channel.json_body)
# There should be one device
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(len(channel.json_body["devices"]), 1)
# Delete the device. UIA should not be required.
channel = self.make_request(
"DELETE",
"/_matrix/client/v3/devices/AABBCCDD?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
# There should be no device again
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(channel.json_body, {"devices": []})
def test_POST_delete_devices(self) -> None:
self.register_appservice_user("alice", self.msc4190_service.token)
# There should be no device
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(channel.json_body, {"devices": []})
# Create a device
channel = self.make_request(
"PUT",
"/_matrix/client/v3/devices/AABBCCDD?user_id=@alice:test",
content={},
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 201, channel.json_body)
# There should be one device
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(len(channel.json_body["devices"]), 1)
# Delete the device with delete_devices
# UIA should not be required.
channel = self.make_request(
"POST",
"/_matrix/client/v3/delete_devices?user_id=@alice:test",
content={"devices": ["AABBCCDD"]},
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
# There should be no device again
channel = self.make_request(
"GET",
"/_matrix/client/v3/devices?user_id=@alice:test",
access_token=self.msc4190_service.token,
)
self.assertEqual(channel.code, 200, channel.json_body)
self.assertEqual(channel.json_body, {"devices": []})

View file

@ -120,6 +120,34 @@ class RegisterRestServletTestCase(unittest.HomeserverTestCase):
self.assertEqual(channel.code, 401, msg=channel.result) self.assertEqual(channel.code, 401, msg=channel.result)
def test_POST_appservice_msc4190_enabled(self) -> None:
# With MSC4190 enabled, the registration should *not* return an access token
user_id = "@as_user_kermit:test"
as_token = "i_am_an_app_service"
appservice = ApplicationService(
as_token,
id="1234",
namespaces={"users": [{"regex": r"@as_user.*", "exclusive": True}]},
sender="@as:test",
msc4190_device_management=True,
)
self.hs.get_datastores().main.services_cache.append(appservice)
request_data = {
"username": "as_user_kermit",
"type": APP_SERVICE_REGISTRATION_TYPE,
}
channel = self.make_request(
b"POST", self.url + b"?access_token=i_am_an_app_service", request_data
)
self.assertEqual(channel.code, 200, msg=channel.result)
det_data = {"user_id": user_id, "home_server": self.hs.hostname}
self.assertLessEqual(det_data.items(), channel.json_body.items())
self.assertNotIn("access_token", channel.json_body)
def test_POST_bad_password(self) -> None: def test_POST_bad_password(self) -> None:
request_data = {"username": "kermit", "password": 666} request_data = {"username": "kermit", "password": 666}
channel = self.make_request(b"POST", self.url, request_data) channel = self.make_request(b"POST", self.url, request_data)

View file

@ -781,7 +781,7 @@ class HomeserverTestCase(TestCase):
self, self,
username: str, username: str,
appservice_token: str, appservice_token: str,
) -> Tuple[str, str]: ) -> Tuple[str, Optional[str]]:
"""Register an appservice user as an application service. """Register an appservice user as an application service.
Requires the client-facing registration API be registered. Requires the client-facing registration API be registered.
@ -805,7 +805,7 @@ class HomeserverTestCase(TestCase):
access_token=appservice_token, access_token=appservice_token,
) )
self.assertEqual(channel.code, 200, channel.json_body) self.assertEqual(channel.code, 200, channel.json_body)
return channel.json_body["user_id"], channel.json_body["device_id"] return channel.json_body["user_id"], channel.json_body.get("device_id")
def login( def login(
self, self,