test_password_providers.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601
  1. # Copyright 2020 The Matrix.org Foundation C.I.C.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. """Tests for the password_auth_provider interface"""
  15. from typing import Any, Type, Union
  16. from unittest.mock import Mock
  17. from twisted.internet import defer
  18. import synapse
  19. from synapse.rest.client import devices, login
  20. from synapse.types import JsonDict
  21. from tests import unittest
  22. from tests.server import FakeChannel
  23. from tests.unittest import override_config
  24. # (possibly experimental) login flows we expect to appear in the list after the normal
  25. # ones
  26. ADDITIONAL_LOGIN_FLOWS = [{"type": "uk.half-shot.msc2778.login.application_service"}]
  27. # a mock instance which the dummy auth providers delegate to, so we can see what's going
  28. # on
  29. mock_password_provider = Mock()
  30. class PasswordOnlyAuthProvider:
  31. """A password_provider which only implements `check_password`."""
  32. @staticmethod
  33. def parse_config(self):
  34. pass
  35. def __init__(self, config, account_handler):
  36. pass
  37. def check_password(self, *args):
  38. return mock_password_provider.check_password(*args)
  39. class CustomAuthProvider:
  40. """A password_provider which implements a custom login type."""
  41. @staticmethod
  42. def parse_config(self):
  43. pass
  44. def __init__(self, config, account_handler):
  45. pass
  46. def get_supported_login_types(self):
  47. return {"test.login_type": ["test_field"]}
  48. def check_auth(self, *args):
  49. return mock_password_provider.check_auth(*args)
  50. class PasswordCustomAuthProvider:
  51. """A password_provider which implements password login via `check_auth`, as well
  52. as a custom type."""
  53. @staticmethod
  54. def parse_config(self):
  55. pass
  56. def __init__(self, config, account_handler):
  57. pass
  58. def get_supported_login_types(self):
  59. return {"m.login.password": ["password"], "test.login_type": ["test_field"]}
  60. def check_auth(self, *args):
  61. return mock_password_provider.check_auth(*args)
  62. def providers_config(*providers: Type[Any]) -> dict:
  63. """Returns a config dict that will enable the given password auth providers"""
  64. return {
  65. "password_providers": [
  66. {"module": "%s.%s" % (__name__, provider.__qualname__), "config": {}}
  67. for provider in providers
  68. ]
  69. }
  70. class PasswordAuthProviderTests(unittest.HomeserverTestCase):
  71. servlets = [
  72. synapse.rest.admin.register_servlets,
  73. login.register_servlets,
  74. devices.register_servlets,
  75. ]
  76. def setUp(self):
  77. # we use a global mock device, so make sure we are starting with a clean slate
  78. mock_password_provider.reset_mock()
  79. super().setUp()
  80. @override_config(providers_config(PasswordOnlyAuthProvider))
  81. def test_password_only_auth_provider_login(self):
  82. # login flows should only have m.login.password
  83. flows = self._get_login_flows()
  84. self.assertEqual(flows, [{"type": "m.login.password"}] + ADDITIONAL_LOGIN_FLOWS)
  85. # check_password must return an awaitable
  86. mock_password_provider.check_password.return_value = defer.succeed(True)
  87. channel = self._send_password_login("u", "p")
  88. self.assertEqual(channel.code, 200, channel.result)
  89. self.assertEqual("@u:test", channel.json_body["user_id"])
  90. mock_password_provider.check_password.assert_called_once_with("@u:test", "p")
  91. mock_password_provider.reset_mock()
  92. # login with mxid should work too
  93. channel = self._send_password_login("@u:bz", "p")
  94. self.assertEqual(channel.code, 200, channel.result)
  95. self.assertEqual("@u:bz", channel.json_body["user_id"])
  96. mock_password_provider.check_password.assert_called_once_with("@u:bz", "p")
  97. mock_password_provider.reset_mock()
  98. # try a weird username / pass. Honestly it's unclear what we *expect* to happen
  99. # in these cases, but at least we can guard against the API changing
  100. # unexpectedly
  101. channel = self._send_password_login(" USER🙂NAME ", " pASS\U0001F622word ")
  102. self.assertEqual(channel.code, 200, channel.result)
  103. self.assertEqual("@ USER🙂NAME :test", channel.json_body["user_id"])
  104. mock_password_provider.check_password.assert_called_once_with(
  105. "@ USER🙂NAME :test", " pASS😢word "
  106. )
  107. @override_config(providers_config(PasswordOnlyAuthProvider))
  108. def test_password_only_auth_provider_ui_auth(self):
  109. """UI Auth should delegate correctly to the password provider"""
  110. # create the user, otherwise access doesn't work
  111. module_api = self.hs.get_module_api()
  112. self.get_success(module_api.register_user("u"))
  113. # log in twice, to get two devices
  114. mock_password_provider.check_password.return_value = defer.succeed(True)
  115. tok1 = self.login("u", "p")
  116. self.login("u", "p", device_id="dev2")
  117. mock_password_provider.reset_mock()
  118. # have the auth provider deny the request to start with
  119. mock_password_provider.check_password.return_value = defer.succeed(False)
  120. # make the initial request which returns a 401
  121. session = self._start_delete_device_session(tok1, "dev2")
  122. mock_password_provider.check_password.assert_not_called()
  123. # Make another request providing the UI auth flow.
  124. channel = self._authed_delete_device(tok1, "dev2", session, "u", "p")
  125. self.assertEqual(channel.code, 401) # XXX why not a 403?
  126. self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
  127. mock_password_provider.check_password.assert_called_once_with("@u:test", "p")
  128. mock_password_provider.reset_mock()
  129. # Finally, check the request goes through when we allow it
  130. mock_password_provider.check_password.return_value = defer.succeed(True)
  131. channel = self._authed_delete_device(tok1, "dev2", session, "u", "p")
  132. self.assertEqual(channel.code, 200)
  133. mock_password_provider.check_password.assert_called_once_with("@u:test", "p")
  134. @override_config(providers_config(PasswordOnlyAuthProvider))
  135. def test_local_user_fallback_login(self):
  136. """rejected login should fall back to local db"""
  137. self.register_user("localuser", "localpass")
  138. # check_password must return an awaitable
  139. mock_password_provider.check_password.return_value = defer.succeed(False)
  140. channel = self._send_password_login("u", "p")
  141. self.assertEqual(channel.code, 403, channel.result)
  142. channel = self._send_password_login("localuser", "localpass")
  143. self.assertEqual(channel.code, 200, channel.result)
  144. self.assertEqual("@localuser:test", channel.json_body["user_id"])
  145. @override_config(providers_config(PasswordOnlyAuthProvider))
  146. def test_local_user_fallback_ui_auth(self):
  147. """rejected login should fall back to local db"""
  148. self.register_user("localuser", "localpass")
  149. # have the auth provider deny the request
  150. mock_password_provider.check_password.return_value = defer.succeed(False)
  151. # log in twice, to get two devices
  152. tok1 = self.login("localuser", "localpass")
  153. self.login("localuser", "localpass", device_id="dev2")
  154. mock_password_provider.check_password.reset_mock()
  155. # first delete should give a 401
  156. session = self._start_delete_device_session(tok1, "dev2")
  157. mock_password_provider.check_password.assert_not_called()
  158. # Wrong password
  159. channel = self._authed_delete_device(tok1, "dev2", session, "localuser", "xxx")
  160. self.assertEqual(channel.code, 401) # XXX why not a 403?
  161. self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
  162. mock_password_provider.check_password.assert_called_once_with(
  163. "@localuser:test", "xxx"
  164. )
  165. mock_password_provider.reset_mock()
  166. # Right password
  167. channel = self._authed_delete_device(
  168. tok1, "dev2", session, "localuser", "localpass"
  169. )
  170. self.assertEqual(channel.code, 200)
  171. mock_password_provider.check_password.assert_called_once_with(
  172. "@localuser:test", "localpass"
  173. )
  174. @override_config(
  175. {
  176. **providers_config(PasswordOnlyAuthProvider),
  177. "password_config": {"localdb_enabled": False},
  178. }
  179. )
  180. def test_no_local_user_fallback_login(self):
  181. """localdb_enabled can block login with the local password"""
  182. self.register_user("localuser", "localpass")
  183. # check_password must return an awaitable
  184. mock_password_provider.check_password.return_value = defer.succeed(False)
  185. channel = self._send_password_login("localuser", "localpass")
  186. self.assertEqual(channel.code, 403)
  187. self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
  188. mock_password_provider.check_password.assert_called_once_with(
  189. "@localuser:test", "localpass"
  190. )
  191. @override_config(
  192. {
  193. **providers_config(PasswordOnlyAuthProvider),
  194. "password_config": {"localdb_enabled": False},
  195. }
  196. )
  197. def test_no_local_user_fallback_ui_auth(self):
  198. """localdb_enabled can block ui auth with the local password"""
  199. self.register_user("localuser", "localpass")
  200. # allow login via the auth provider
  201. mock_password_provider.check_password.return_value = defer.succeed(True)
  202. # log in twice, to get two devices
  203. tok1 = self.login("localuser", "p")
  204. self.login("localuser", "p", device_id="dev2")
  205. mock_password_provider.check_password.reset_mock()
  206. # first delete should give a 401
  207. channel = self._delete_device(tok1, "dev2")
  208. self.assertEqual(channel.code, 401)
  209. # m.login.password UIA is permitted because the auth provider allows it,
  210. # even though the localdb does not.
  211. self.assertEqual(channel.json_body["flows"], [{"stages": ["m.login.password"]}])
  212. session = channel.json_body["session"]
  213. mock_password_provider.check_password.assert_not_called()
  214. # now try deleting with the local password
  215. mock_password_provider.check_password.return_value = defer.succeed(False)
  216. channel = self._authed_delete_device(
  217. tok1, "dev2", session, "localuser", "localpass"
  218. )
  219. self.assertEqual(channel.code, 401) # XXX why not a 403?
  220. self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
  221. mock_password_provider.check_password.assert_called_once_with(
  222. "@localuser:test", "localpass"
  223. )
  224. @override_config(
  225. {
  226. **providers_config(PasswordOnlyAuthProvider),
  227. "password_config": {"enabled": False},
  228. }
  229. )
  230. def test_password_auth_disabled(self):
  231. """password auth doesn't work if it's disabled across the board"""
  232. # login flows should be empty
  233. flows = self._get_login_flows()
  234. self.assertEqual(flows, ADDITIONAL_LOGIN_FLOWS)
  235. # login shouldn't work and should be rejected with a 400 ("unknown login type")
  236. channel = self._send_password_login("u", "p")
  237. self.assertEqual(channel.code, 400, channel.result)
  238. mock_password_provider.check_password.assert_not_called()
  239. @override_config(providers_config(CustomAuthProvider))
  240. def test_custom_auth_provider_login(self):
  241. # login flows should have the custom flow and m.login.password, since we
  242. # haven't disabled local password lookup.
  243. # (password must come first, because reasons)
  244. flows = self._get_login_flows()
  245. self.assertEqual(
  246. flows,
  247. [{"type": "m.login.password"}, {"type": "test.login_type"}]
  248. + ADDITIONAL_LOGIN_FLOWS,
  249. )
  250. # login with missing param should be rejected
  251. channel = self._send_login("test.login_type", "u")
  252. self.assertEqual(channel.code, 400, channel.result)
  253. mock_password_provider.check_auth.assert_not_called()
  254. mock_password_provider.check_auth.return_value = defer.succeed("@user:bz")
  255. channel = self._send_login("test.login_type", "u", test_field="y")
  256. self.assertEqual(channel.code, 200, channel.result)
  257. self.assertEqual("@user:bz", channel.json_body["user_id"])
  258. mock_password_provider.check_auth.assert_called_once_with(
  259. "u", "test.login_type", {"test_field": "y"}
  260. )
  261. mock_password_provider.reset_mock()
  262. # try a weird username. Again, it's unclear what we *expect* to happen
  263. # in these cases, but at least we can guard against the API changing
  264. # unexpectedly
  265. mock_password_provider.check_auth.return_value = defer.succeed(
  266. "@ MALFORMED! :bz"
  267. )
  268. channel = self._send_login("test.login_type", " USER🙂NAME ", test_field=" abc ")
  269. self.assertEqual(channel.code, 200, channel.result)
  270. self.assertEqual("@ MALFORMED! :bz", channel.json_body["user_id"])
  271. mock_password_provider.check_auth.assert_called_once_with(
  272. " USER🙂NAME ", "test.login_type", {"test_field": " abc "}
  273. )
  274. @override_config(providers_config(CustomAuthProvider))
  275. def test_custom_auth_provider_ui_auth(self):
  276. # register the user and log in twice, to get two devices
  277. self.register_user("localuser", "localpass")
  278. tok1 = self.login("localuser", "localpass")
  279. self.login("localuser", "localpass", device_id="dev2")
  280. # make the initial request which returns a 401
  281. channel = self._delete_device(tok1, "dev2")
  282. self.assertEqual(channel.code, 401)
  283. # Ensure that flows are what is expected.
  284. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  285. self.assertIn({"stages": ["test.login_type"]}, channel.json_body["flows"])
  286. session = channel.json_body["session"]
  287. # missing param
  288. body = {
  289. "auth": {
  290. "type": "test.login_type",
  291. "identifier": {"type": "m.id.user", "user": "localuser"},
  292. "session": session,
  293. },
  294. }
  295. channel = self._delete_device(tok1, "dev2", body)
  296. self.assertEqual(channel.code, 400)
  297. # there's a perfectly good M_MISSING_PARAM errcode, but heaven forfend we should
  298. # use it...
  299. self.assertIn("Missing parameters", channel.json_body["error"])
  300. mock_password_provider.check_auth.assert_not_called()
  301. mock_password_provider.reset_mock()
  302. # right params, but authing as the wrong user
  303. mock_password_provider.check_auth.return_value = defer.succeed("@user:bz")
  304. body["auth"]["test_field"] = "foo"
  305. channel = self._delete_device(tok1, "dev2", body)
  306. self.assertEqual(channel.code, 403)
  307. self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN")
  308. mock_password_provider.check_auth.assert_called_once_with(
  309. "localuser", "test.login_type", {"test_field": "foo"}
  310. )
  311. mock_password_provider.reset_mock()
  312. # and finally, succeed
  313. mock_password_provider.check_auth.return_value = defer.succeed(
  314. "@localuser:test"
  315. )
  316. channel = self._delete_device(tok1, "dev2", body)
  317. self.assertEqual(channel.code, 200)
  318. mock_password_provider.check_auth.assert_called_once_with(
  319. "localuser", "test.login_type", {"test_field": "foo"}
  320. )
  321. @override_config(providers_config(CustomAuthProvider))
  322. def test_custom_auth_provider_callback(self):
  323. callback = Mock(return_value=defer.succeed(None))
  324. mock_password_provider.check_auth.return_value = defer.succeed(
  325. ("@user:bz", callback)
  326. )
  327. channel = self._send_login("test.login_type", "u", test_field="y")
  328. self.assertEqual(channel.code, 200, channel.result)
  329. self.assertEqual("@user:bz", channel.json_body["user_id"])
  330. mock_password_provider.check_auth.assert_called_once_with(
  331. "u", "test.login_type", {"test_field": "y"}
  332. )
  333. # check the args to the callback
  334. callback.assert_called_once()
  335. call_args, call_kwargs = callback.call_args
  336. # should be one positional arg
  337. self.assertEqual(len(call_args), 1)
  338. self.assertEqual(call_args[0]["user_id"], "@user:bz")
  339. for p in ["user_id", "access_token", "device_id", "home_server"]:
  340. self.assertIn(p, call_args[0])
  341. @override_config(
  342. {**providers_config(CustomAuthProvider), "password_config": {"enabled": False}}
  343. )
  344. def test_custom_auth_password_disabled(self):
  345. """Test login with a custom auth provider where password login is disabled"""
  346. self.register_user("localuser", "localpass")
  347. flows = self._get_login_flows()
  348. self.assertEqual(flows, [{"type": "test.login_type"}] + ADDITIONAL_LOGIN_FLOWS)
  349. # login shouldn't work and should be rejected with a 400 ("unknown login type")
  350. channel = self._send_password_login("localuser", "localpass")
  351. self.assertEqual(channel.code, 400, channel.result)
  352. mock_password_provider.check_auth.assert_not_called()
  353. @override_config(
  354. {
  355. **providers_config(CustomAuthProvider),
  356. "password_config": {"enabled": False, "localdb_enabled": False},
  357. }
  358. )
  359. def test_custom_auth_password_disabled_localdb_enabled(self):
  360. """Check the localdb_enabled == enabled == False
  361. Regression test for https://github.com/matrix-org/synapse/issues/8914: check
  362. that setting *both* `localdb_enabled` *and* `password: enabled` to False doesn't
  363. cause an exception.
  364. """
  365. self.register_user("localuser", "localpass")
  366. flows = self._get_login_flows()
  367. self.assertEqual(flows, [{"type": "test.login_type"}] + ADDITIONAL_LOGIN_FLOWS)
  368. # login shouldn't work and should be rejected with a 400 ("unknown login type")
  369. channel = self._send_password_login("localuser", "localpass")
  370. self.assertEqual(channel.code, 400, channel.result)
  371. mock_password_provider.check_auth.assert_not_called()
  372. @override_config(
  373. {
  374. **providers_config(PasswordCustomAuthProvider),
  375. "password_config": {"enabled": False},
  376. }
  377. )
  378. def test_password_custom_auth_password_disabled_login(self):
  379. """log in with a custom auth provider which implements password, but password
  380. login is disabled"""
  381. self.register_user("localuser", "localpass")
  382. flows = self._get_login_flows()
  383. self.assertEqual(flows, [{"type": "test.login_type"}] + ADDITIONAL_LOGIN_FLOWS)
  384. # login shouldn't work and should be rejected with a 400 ("unknown login type")
  385. channel = self._send_password_login("localuser", "localpass")
  386. self.assertEqual(channel.code, 400, channel.result)
  387. mock_password_provider.check_auth.assert_not_called()
  388. @override_config(
  389. {
  390. **providers_config(PasswordCustomAuthProvider),
  391. "password_config": {"enabled": False},
  392. }
  393. )
  394. def test_password_custom_auth_password_disabled_ui_auth(self):
  395. """UI Auth with a custom auth provider which implements password, but password
  396. login is disabled"""
  397. # register the user and log in twice via the test login type to get two devices,
  398. self.register_user("localuser", "localpass")
  399. mock_password_provider.check_auth.return_value = defer.succeed(
  400. "@localuser:test"
  401. )
  402. channel = self._send_login("test.login_type", "localuser", test_field="")
  403. self.assertEqual(channel.code, 200, channel.result)
  404. tok1 = channel.json_body["access_token"]
  405. channel = self._send_login(
  406. "test.login_type", "localuser", test_field="", device_id="dev2"
  407. )
  408. self.assertEqual(channel.code, 200, channel.result)
  409. # make the initial request which returns a 401
  410. channel = self._delete_device(tok1, "dev2")
  411. self.assertEqual(channel.code, 401)
  412. # Ensure that flows are what is expected. In particular, "password" should *not*
  413. # be present.
  414. self.assertIn({"stages": ["test.login_type"]}, channel.json_body["flows"])
  415. session = channel.json_body["session"]
  416. mock_password_provider.reset_mock()
  417. # check that auth with password is rejected
  418. body = {
  419. "auth": {
  420. "type": "m.login.password",
  421. "identifier": {"type": "m.id.user", "user": "localuser"},
  422. "password": "localpass",
  423. "session": session,
  424. },
  425. }
  426. channel = self._delete_device(tok1, "dev2", body)
  427. self.assertEqual(channel.code, 400)
  428. self.assertEqual(
  429. "Password login has been disabled.", channel.json_body["error"]
  430. )
  431. mock_password_provider.check_auth.assert_not_called()
  432. mock_password_provider.reset_mock()
  433. # successful auth
  434. body["auth"]["type"] = "test.login_type"
  435. body["auth"]["test_field"] = "x"
  436. channel = self._delete_device(tok1, "dev2", body)
  437. self.assertEqual(channel.code, 200)
  438. mock_password_provider.check_auth.assert_called_once_with(
  439. "localuser", "test.login_type", {"test_field": "x"}
  440. )
  441. @override_config(
  442. {
  443. **providers_config(CustomAuthProvider),
  444. "password_config": {"localdb_enabled": False},
  445. }
  446. )
  447. def test_custom_auth_no_local_user_fallback(self):
  448. """Test login with a custom auth provider where the local db is disabled"""
  449. self.register_user("localuser", "localpass")
  450. flows = self._get_login_flows()
  451. self.assertEqual(flows, [{"type": "test.login_type"}] + ADDITIONAL_LOGIN_FLOWS)
  452. # password login shouldn't work and should be rejected with a 400
  453. # ("unknown login type")
  454. channel = self._send_password_login("localuser", "localpass")
  455. self.assertEqual(channel.code, 400, channel.result)
  456. def _get_login_flows(self) -> JsonDict:
  457. channel = self.make_request("GET", "/_matrix/client/r0/login")
  458. self.assertEqual(channel.code, 200, channel.result)
  459. return channel.json_body["flows"]
  460. def _send_password_login(self, user: str, password: str) -> FakeChannel:
  461. return self._send_login(type="m.login.password", user=user, password=password)
  462. def _send_login(self, type, user, **params) -> FakeChannel:
  463. params.update({"identifier": {"type": "m.id.user", "user": user}, "type": type})
  464. channel = self.make_request("POST", "/_matrix/client/r0/login", params)
  465. return channel
  466. def _start_delete_device_session(self, access_token, device_id) -> str:
  467. """Make an initial delete device request, and return the UI Auth session ID"""
  468. channel = self._delete_device(access_token, device_id)
  469. self.assertEqual(channel.code, 401)
  470. # Ensure that flows are what is expected.
  471. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  472. return channel.json_body["session"]
  473. def _authed_delete_device(
  474. self,
  475. access_token: str,
  476. device_id: str,
  477. session: str,
  478. user_id: str,
  479. password: str,
  480. ) -> FakeChannel:
  481. """Make a delete device request, authenticating with the given uid/password"""
  482. return self._delete_device(
  483. access_token,
  484. device_id,
  485. {
  486. "auth": {
  487. "type": "m.login.password",
  488. "identifier": {"type": "m.id.user", "user": user_id},
  489. "password": password,
  490. "session": session,
  491. },
  492. },
  493. )
  494. def _delete_device(
  495. self,
  496. access_token: str,
  497. device: str,
  498. body: Union[JsonDict, bytes] = b"",
  499. ) -> FakeChannel:
  500. """Delete an individual device."""
  501. channel = self.make_request(
  502. "DELETE", "devices/" + device, body, access_token=access_token
  503. )
  504. return channel