test_password_providers.py 24 KB

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