test_password_providers.py 24 KB

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