Skip to content

Commit 23b626f

Browse files
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 <[email protected]>
1 parent abf44ad commit 23b626f

File tree

12 files changed

+351
-33
lines changed

12 files changed

+351
-33
lines changed

changelog.d/17705.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support for [MSC4190](https://github.com/matrix-org/matrix-spec-proposals/pull/4190): device management for Application Services.

synapse/appservice/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def __init__(
8787
ip_range_whitelist: Optional[IPSet] = None,
8888
supports_ephemeral: bool = False,
8989
msc3202_transaction_extensions: bool = False,
90+
msc4190_device_management: bool = False,
9091
):
9192
self.token = token
9293
self.url = (
@@ -100,6 +101,7 @@ def __init__(
100101
self.ip_range_whitelist = ip_range_whitelist
101102
self.supports_ephemeral = supports_ephemeral
102103
self.msc3202_transaction_extensions = msc3202_transaction_extensions
104+
self.msc4190_device_management = msc4190_device_management
103105

104106
if "|" in self.id:
105107
raise Exception("application service ID cannot contain '|' character")

synapse/config/appservice.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,18 @@ def _load_appservice(
183183
"The `org.matrix.msc3202` option should be true or false if specified."
184184
)
185185

186+
# Opt-in flag for the MSC4190 behaviours.
187+
# When enabled, the following C-S API endpoints change for appservices:
188+
# - POST /register does not return an access token
189+
# - PUT /devices/{device_id} creates a new device if one does not exist
190+
# - DELETE /devices/{device_id} no longer requires UIA
191+
# - POST /delete_devices/{device_id} no longer requires UIA
192+
msc4190_enabled = as_info.get("io.element.msc4190", False)
193+
if not isinstance(msc4190_enabled, bool):
194+
raise ValueError(
195+
"The `io.element.msc4190` option should be true or false if specified."
196+
)
197+
186198
return ApplicationService(
187199
token=as_info["as_token"],
188200
url=as_info["url"],
@@ -195,4 +207,5 @@ def _load_appservice(
195207
ip_range_whitelist=ip_range_whitelist,
196208
supports_ephemeral=supports_ephemeral,
197209
msc3202_transaction_extensions=msc3202_transaction_extensions,
210+
msc4190_device_management=msc4190_enabled,
198211
)

synapse/handlers/device.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -729,6 +729,40 @@ async def delete_devices(self, user_id: str, device_ids: List[str]) -> None:
729729

730730
await self.notify_device_update(user_id, device_ids)
731731

732+
async def upsert_device(
733+
self, user_id: str, device_id: str, display_name: Optional[str] = None
734+
) -> bool:
735+
"""Create or update a device
736+
737+
Args:
738+
user_id: The user to update devices of.
739+
device_id: The device to update.
740+
display_name: The new display name for this device.
741+
742+
Returns:
743+
True if the device was created, False if it was updated.
744+
745+
"""
746+
747+
# Reject a new displayname which is too long.
748+
self._check_device_name_length(display_name)
749+
750+
created = await self.store.store_device(
751+
user_id,
752+
device_id,
753+
initial_device_display_name=display_name,
754+
)
755+
756+
if not created:
757+
await self.store.update_device(
758+
user_id,
759+
device_id,
760+
new_display_name=display_name,
761+
)
762+
763+
await self.notify_device_update(user_id, [device_id])
764+
return created
765+
732766
async def update_device(self, user_id: str, device_id: str, content: dict) -> None:
733767
"""Update the given device
734768

synapse/handlers/register.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -630,7 +630,9 @@ async def post_consent_actions(self, user_id: str) -> None:
630630
"""
631631
await self._auto_join_rooms(user_id)
632632

633-
async def appservice_register(self, user_localpart: str, as_token: str) -> str:
633+
async def appservice_register(
634+
self, user_localpart: str, as_token: str
635+
) -> Tuple[str, ApplicationService]:
634636
user = UserID(user_localpart, self.hs.hostname)
635637
user_id = user.to_string()
636638
service = self.store.get_app_service_by_token(as_token)
@@ -653,7 +655,7 @@ async def appservice_register(self, user_localpart: str, as_token: str) -> str:
653655
appservice_id=service_id,
654656
create_profile_with_displayname=user.localpart,
655657
)
656-
return user_id
658+
return (user_id, service)
657659

658660
def check_user_id_not_appservice_exclusive(
659661
self, user_id: str, allowed_appservice: Optional[ApplicationService] = None

synapse/rest/client/devices.py

Lines changed: 41 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -114,15 +114,19 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
114114
else:
115115
raise e
116116

117-
await self.auth_handler.validate_user_via_ui_auth(
118-
requester,
119-
request,
120-
body.dict(exclude_unset=True),
121-
"remove device(s) from your account",
122-
# Users might call this multiple times in a row while cleaning up
123-
# devices, allow a single UI auth session to be re-used.
124-
can_skip_ui_auth=True,
125-
)
117+
if requester.app_service and requester.app_service.msc4190_device_management:
118+
# MSC4190 can skip UIA for this endpoint
119+
pass
120+
else:
121+
await self.auth_handler.validate_user_via_ui_auth(
122+
requester,
123+
request,
124+
body.dict(exclude_unset=True),
125+
"remove device(s) from your account",
126+
# Users might call this multiple times in a row while cleaning up
127+
# devices, allow a single UI auth session to be re-used.
128+
can_skip_ui_auth=True,
129+
)
126130

127131
await self.device_handler.delete_devices(
128132
requester.user.to_string(), body.devices
@@ -175,9 +179,6 @@ class DeleteBody(RequestBodyModel):
175179
async def on_DELETE(
176180
self, request: SynapseRequest, device_id: str
177181
) -> Tuple[int, JsonDict]:
178-
if self._msc3861_oauth_delegation_enabled:
179-
raise UnrecognizedRequestError(code=404)
180-
181182
requester = await self.auth.get_user_by_req(request)
182183

183184
try:
@@ -192,15 +193,24 @@ async def on_DELETE(
192193
else:
193194
raise
194195

195-
await self.auth_handler.validate_user_via_ui_auth(
196-
requester,
197-
request,
198-
body.dict(exclude_unset=True),
199-
"remove a device from your account",
200-
# Users might call this multiple times in a row while cleaning up
201-
# devices, allow a single UI auth session to be re-used.
202-
can_skip_ui_auth=True,
203-
)
196+
if requester.app_service and requester.app_service.msc4190_device_management:
197+
# MSC4190 allows appservices to delete devices through this endpoint without UIA
198+
# It's also allowed with MSC3861 enabled
199+
pass
200+
201+
else:
202+
if self._msc3861_oauth_delegation_enabled:
203+
raise UnrecognizedRequestError(code=404)
204+
205+
await self.auth_handler.validate_user_via_ui_auth(
206+
requester,
207+
request,
208+
body.dict(exclude_unset=True),
209+
"remove a device from your account",
210+
# Users might call this multiple times in a row while cleaning up
211+
# devices, allow a single UI auth session to be re-used.
212+
can_skip_ui_auth=True,
213+
)
204214

205215
await self.device_handler.delete_devices(
206216
requester.user.to_string(), [device_id]
@@ -216,6 +226,16 @@ async def on_PUT(
216226
requester = await self.auth.get_user_by_req(request, allow_guest=True)
217227

218228
body = parse_and_validate_json_object_from_request(request, self.PutBody)
229+
230+
# MSC4190 allows appservices to create devices through this endpoint
231+
if requester.app_service and requester.app_service.msc4190_device_management:
232+
created = await self.device_handler.upsert_device(
233+
user_id=requester.user.to_string(),
234+
device_id=device_id,
235+
display_name=body.display_name,
236+
)
237+
return 201 if created else 200, {}
238+
219239
await self.device_handler.update_device(
220240
requester.user.to_string(), device_id, body.dict()
221241
)

synapse/rest/client/register.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -771,9 +771,12 @@ async def _do_appservice_registration(
771771
body: JsonDict,
772772
should_issue_refresh_token: bool = False,
773773
) -> JsonDict:
774-
user_id = await self.registration_handler.appservice_register(
774+
user_id, appservice = await self.registration_handler.appservice_register(
775775
username, as_token
776776
)
777+
if appservice.msc4190_device_management:
778+
body["inhibit_login"] = True
779+
777780
return await self._create_registration_details(
778781
user_id,
779782
body,
@@ -937,7 +940,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
937940

938941
as_token = self.auth.get_access_token_from_request(request)
939942

940-
user_id = await self.registration_handler.appservice_register(
943+
user_id, _ = await self.registration_handler.appservice_register(
941944
desired_username, as_token
942945
)
943946
return 200, {"user_id": user_id}

tests/handlers/test_appservice.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1165,12 +1165,23 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
11651165
self.hs.get_datastores().main.services_cache = [self._service]
11661166

11671167
# Register some appservice users
1168-
self._sender_user, self._sender_device = self.register_appservice_user(
1168+
user_id, device_id = self.register_appservice_user(
11691169
"as.sender", self._service_token
11701170
)
1171-
self._namespaced_user, self._namespaced_device = self.register_appservice_user(
1171+
# With MSC4190 enabled, there will not be a device created
1172+
# during AS registration. However MSC4190 is not enabled
1173+
# in this test. It may become the default behaviour in the
1174+
# future, in which case this test will need to be updated.
1175+
assert device_id is not None
1176+
self._sender_user = user_id
1177+
self._sender_device = device_id
1178+
1179+
user_id, device_id = self.register_appservice_user(
11721180
"_as_user1", self._service_token
11731181
)
1182+
assert device_id is not None
1183+
self._namespaced_user = user_id
1184+
self._namespaced_device = device_id
11741185

11751186
# Register a real user as well.
11761187
self._real_user = self.register_user("real.user", "meow")

tests/handlers/test_oauth_delegation.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -560,9 +560,15 @@ def expect_unauthorized(
560560
self.assertEqual(channel.code, 401, channel.json_body)
561561

562562
def expect_unrecognized(
563-
self, method: str, path: str, content: Union[bytes, str, JsonDict] = ""
563+
self,
564+
method: str,
565+
path: str,
566+
content: Union[bytes, str, JsonDict] = "",
567+
auth: bool = False,
564568
) -> None:
565-
channel = self.make_request(method, path, content)
569+
channel = self.make_request(
570+
method, path, content, access_token="token" if auth else None
571+
)
566572

567573
self.assertEqual(channel.code, 404, channel.json_body)
568574
self.assertEqual(
@@ -648,8 +654,25 @@ def test_session_management_endpoints_removed(self) -> None:
648654

649655
def test_device_management_endpoints_removed(self) -> None:
650656
"""Test that device management endpoints that were removed in MSC2964 are no longer available."""
651-
self.expect_unrecognized("POST", "/_matrix/client/v3/delete_devices")
652-
self.expect_unrecognized("DELETE", "/_matrix/client/v3/devices/{DEVICE}")
657+
658+
# Because we still support those endpoints with ASes, it checks the
659+
# access token before returning 404
660+
self.http_client.request = AsyncMock(
661+
return_value=FakeResponse.json(
662+
code=200,
663+
payload={
664+
"active": True,
665+
"sub": SUBJECT,
666+
"scope": " ".join([MATRIX_USER_SCOPE, MATRIX_DEVICE_SCOPE]),
667+
"username": USERNAME,
668+
},
669+
)
670+
)
671+
672+
self.expect_unrecognized("POST", "/_matrix/client/v3/delete_devices", auth=True)
673+
self.expect_unrecognized(
674+
"DELETE", "/_matrix/client/v3/devices/{DEVICE}", auth=True
675+
)
653676

654677
def test_openid_endpoints_removed(self) -> None:
655678
"""Test that OpenID id_token endpoints that were removed in MSC2964 are no longer available."""

0 commit comments

Comments
 (0)