You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

1503 lines
54 KiB

  1. # Copyright 2018 New Vector
  2. # Copyright 2020-2021 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. import re
  16. from http import HTTPStatus
  17. from typing import Any, Dict, List, Optional, Tuple, Union
  18. from twisted.internet.defer import succeed
  19. from twisted.test.proto_helpers import MemoryReactor
  20. from twisted.web.resource import Resource
  21. import synapse.rest.admin
  22. from synapse.api.constants import ApprovalNoticeMedium, LoginType
  23. from synapse.api.errors import Codes, SynapseError
  24. from synapse.handlers.ui_auth.checkers import UserInteractiveAuthChecker
  25. from synapse.rest.client import account, auth, devices, login, logout, register
  26. from synapse.rest.synapse.client import build_synapse_client_resource_tree
  27. from synapse.server import HomeServer
  28. from synapse.storage.database import LoggingTransaction
  29. from synapse.types import JsonDict, UserID
  30. from synapse.util import Clock
  31. from tests import unittest
  32. from tests.handlers.test_oidc import HAS_OIDC
  33. from tests.rest.client.utils import TEST_OIDC_CONFIG, TEST_OIDC_ISSUER
  34. from tests.server import FakeChannel
  35. from tests.unittest import override_config, skip_unless
  36. class DummyRecaptchaChecker(UserInteractiveAuthChecker):
  37. def __init__(self, hs: HomeServer) -> None:
  38. super().__init__(hs)
  39. self.recaptcha_attempts: List[Tuple[dict, str]] = []
  40. def is_enabled(self) -> bool:
  41. return True
  42. def check_auth(self, authdict: dict, clientip: str) -> Any:
  43. self.recaptcha_attempts.append((authdict, clientip))
  44. return succeed(True)
  45. class FallbackAuthTests(unittest.HomeserverTestCase):
  46. servlets = [
  47. auth.register_servlets,
  48. register.register_servlets,
  49. ]
  50. hijack_auth = False
  51. def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
  52. config = self.default_config()
  53. config["enable_registration_captcha"] = True
  54. config["recaptcha_public_key"] = "brokencake"
  55. config["registrations_require_3pid"] = []
  56. hs = self.setup_test_homeserver(config=config)
  57. return hs
  58. def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
  59. self.recaptcha_checker = DummyRecaptchaChecker(hs)
  60. auth_handler = hs.get_auth_handler()
  61. auth_handler.checkers[LoginType.RECAPTCHA] = self.recaptcha_checker
  62. def register(self, expected_response: int, body: JsonDict) -> FakeChannel:
  63. """Make a register request."""
  64. channel = self.make_request("POST", "register", body)
  65. self.assertEqual(channel.code, expected_response)
  66. return channel
  67. def recaptcha(
  68. self,
  69. session: str,
  70. expected_post_response: int,
  71. post_session: Optional[str] = None,
  72. ) -> None:
  73. """Get and respond to a fallback recaptcha. Returns the second request."""
  74. if post_session is None:
  75. post_session = session
  76. channel = self.make_request(
  77. "GET", "auth/m.login.recaptcha/fallback/web?session=" + session
  78. )
  79. self.assertEqual(channel.code, HTTPStatus.OK)
  80. channel = self.make_request(
  81. "POST",
  82. "auth/m.login.recaptcha/fallback/web?session="
  83. + post_session
  84. + "&g-recaptcha-response=a",
  85. )
  86. self.assertEqual(channel.code, expected_post_response)
  87. # The recaptcha handler is called with the response given
  88. attempts = self.recaptcha_checker.recaptcha_attempts
  89. self.assertEqual(len(attempts), 1)
  90. self.assertEqual(attempts[0][0]["response"], "a")
  91. def test_fallback_captcha(self) -> None:
  92. """Ensure that fallback auth via a captcha works."""
  93. # Returns a 401 as per the spec
  94. channel = self.register(
  95. HTTPStatus.UNAUTHORIZED,
  96. {"username": "user", "type": "m.login.password", "password": "bar"},
  97. )
  98. # Grab the session
  99. session = channel.json_body["session"]
  100. # Assert our configured public key is being given
  101. self.assertEqual(
  102. channel.json_body["params"]["m.login.recaptcha"]["public_key"], "brokencake"
  103. )
  104. # Complete the recaptcha step.
  105. self.recaptcha(session, HTTPStatus.OK)
  106. # also complete the dummy auth
  107. self.register(
  108. HTTPStatus.OK, {"auth": {"session": session, "type": "m.login.dummy"}}
  109. )
  110. # Now we should have fulfilled a complete auth flow, including
  111. # the recaptcha fallback step, we can then send a
  112. # request to the register API with the session in the authdict.
  113. channel = self.register(HTTPStatus.OK, {"auth": {"session": session}})
  114. # We're given a registered user.
  115. self.assertEqual(channel.json_body["user_id"], "@user:test")
  116. def test_complete_operation_unknown_session(self) -> None:
  117. """
  118. Attempting to mark an invalid session as complete should error.
  119. """
  120. # Make the initial request to register. (Later on a different password
  121. # will be used.)
  122. # Returns a 401 as per the spec
  123. channel = self.register(
  124. HTTPStatus.UNAUTHORIZED,
  125. {"username": "user", "type": "m.login.password", "password": "bar"},
  126. )
  127. # Grab the session
  128. session = channel.json_body["session"]
  129. # Assert our configured public key is being given
  130. self.assertEqual(
  131. channel.json_body["params"]["m.login.recaptcha"]["public_key"], "brokencake"
  132. )
  133. # Attempt to complete the recaptcha step with an unknown session.
  134. # This results in an error.
  135. self.recaptcha(session, 400, session + "unknown")
  136. class UIAuthTests(unittest.HomeserverTestCase):
  137. servlets = [
  138. auth.register_servlets,
  139. devices.register_servlets,
  140. login.register_servlets,
  141. synapse.rest.admin.register_servlets_for_client_rest_resource,
  142. register.register_servlets,
  143. ]
  144. def default_config(self) -> Dict[str, Any]:
  145. config = super().default_config()
  146. # public_baseurl uses an http:// scheme because FakeChannel.isSecure() returns
  147. # False, so synapse will see the requested uri as http://..., so using http in
  148. # the public_baseurl stops Synapse trying to redirect to https.
  149. config["public_baseurl"] = "http://synapse.test"
  150. if HAS_OIDC:
  151. # we enable OIDC as a way of testing SSO flows
  152. oidc_config = {}
  153. oidc_config.update(TEST_OIDC_CONFIG)
  154. oidc_config["allow_existing_users"] = True
  155. config["oidc_config"] = oidc_config
  156. return config
  157. def create_resource_dict(self) -> Dict[str, Resource]:
  158. resource_dict = super().create_resource_dict()
  159. resource_dict.update(build_synapse_client_resource_tree(self.hs))
  160. return resource_dict
  161. def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
  162. self.user_pass = "pass"
  163. self.user = self.register_user("test", self.user_pass)
  164. self.device_id = "dev1"
  165. # Force-enable password login for just long enough to log in.
  166. auth_handler = self.hs.get_auth_handler()
  167. allow_auth_for_login = auth_handler._password_enabled_for_login
  168. auth_handler._password_enabled_for_login = True
  169. self.user_tok = self.login("test", self.user_pass, self.device_id)
  170. # Restore password login to however it was.
  171. auth_handler._password_enabled_for_login = allow_auth_for_login
  172. def delete_device(
  173. self,
  174. access_token: str,
  175. device: str,
  176. expected_response: int,
  177. body: Union[bytes, JsonDict] = b"",
  178. ) -> FakeChannel:
  179. """Delete an individual device."""
  180. channel = self.make_request(
  181. "DELETE",
  182. "devices/" + device,
  183. body,
  184. access_token=access_token,
  185. )
  186. # Ensure the response is sane.
  187. self.assertEqual(channel.code, expected_response)
  188. return channel
  189. def delete_devices(self, expected_response: int, body: JsonDict) -> FakeChannel:
  190. """Delete 1 or more devices."""
  191. # Note that this uses the delete_devices endpoint so that we can modify
  192. # the payload half-way through some tests.
  193. channel = self.make_request(
  194. "POST",
  195. "delete_devices",
  196. body,
  197. access_token=self.user_tok,
  198. )
  199. # Ensure the response is sane.
  200. self.assertEqual(channel.code, expected_response)
  201. return channel
  202. def test_ui_auth(self) -> None:
  203. """
  204. Test user interactive authentication outside of registration.
  205. """
  206. # Attempt to delete this device.
  207. # Returns a 401 as per the spec
  208. channel = self.delete_device(
  209. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  210. )
  211. # Grab the session
  212. session = channel.json_body["session"]
  213. # Ensure that flows are what is expected.
  214. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  215. # Make another request providing the UI auth flow.
  216. self.delete_device(
  217. self.user_tok,
  218. self.device_id,
  219. HTTPStatus.OK,
  220. {
  221. "auth": {
  222. "type": "m.login.password",
  223. "identifier": {"type": "m.id.user", "user": self.user},
  224. "password": self.user_pass,
  225. "session": session,
  226. },
  227. },
  228. )
  229. @override_config({"password_config": {"enabled": "only_for_reauth"}})
  230. def test_ui_auth_with_passwords_for_reauth_only(self) -> None:
  231. """
  232. Test user interactive authentication outside of registration.
  233. """
  234. # Attempt to delete this device.
  235. # Returns a 401 as per the spec
  236. channel = self.delete_device(
  237. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  238. )
  239. # Grab the session
  240. session = channel.json_body["session"]
  241. # Ensure that flows are what is expected.
  242. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  243. # Make another request providing the UI auth flow.
  244. self.delete_device(
  245. self.user_tok,
  246. self.device_id,
  247. HTTPStatus.OK,
  248. {
  249. "auth": {
  250. "type": "m.login.password",
  251. "identifier": {"type": "m.id.user", "user": self.user},
  252. "password": self.user_pass,
  253. "session": session,
  254. },
  255. },
  256. )
  257. def test_grandfathered_identifier(self) -> None:
  258. """Check behaviour without "identifier" dict
  259. Synapse used to require clients to submit a "user" field for m.login.password
  260. UIA - check that still works.
  261. """
  262. channel = self.delete_device(
  263. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  264. )
  265. session = channel.json_body["session"]
  266. # Make another request providing the UI auth flow.
  267. self.delete_device(
  268. self.user_tok,
  269. self.device_id,
  270. HTTPStatus.OK,
  271. {
  272. "auth": {
  273. "type": "m.login.password",
  274. "user": self.user,
  275. "password": self.user_pass,
  276. "session": session,
  277. },
  278. },
  279. )
  280. def test_can_change_body(self) -> None:
  281. """
  282. The client dict can be modified during the user interactive authentication session.
  283. Note that it is not spec compliant to modify the client dict during a
  284. user interactive authentication session, but many clients currently do.
  285. When Synapse is updated to be spec compliant, the call to re-use the
  286. session ID should be rejected.
  287. """
  288. # Create a second login.
  289. self.login("test", self.user_pass, "dev2")
  290. # Attempt to delete the first device.
  291. # Returns a 401 as per the spec
  292. channel = self.delete_devices(
  293. HTTPStatus.UNAUTHORIZED, {"devices": [self.device_id]}
  294. )
  295. # Grab the session
  296. session = channel.json_body["session"]
  297. # Ensure that flows are what is expected.
  298. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  299. # Make another request providing the UI auth flow, but try to delete the
  300. # second device.
  301. self.delete_devices(
  302. HTTPStatus.OK,
  303. {
  304. "devices": ["dev2"],
  305. "auth": {
  306. "type": "m.login.password",
  307. "identifier": {"type": "m.id.user", "user": self.user},
  308. "password": self.user_pass,
  309. "session": session,
  310. },
  311. },
  312. )
  313. def test_cannot_change_uri(self) -> None:
  314. """
  315. The initial requested URI cannot be modified during the user interactive authentication session.
  316. """
  317. # Create a second login.
  318. self.login("test", self.user_pass, "dev2")
  319. # Attempt to delete the first device.
  320. # Returns a 401 as per the spec
  321. channel = self.delete_device(
  322. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  323. )
  324. # Grab the session
  325. session = channel.json_body["session"]
  326. # Ensure that flows are what is expected.
  327. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  328. # Make another request providing the UI auth flow, but try to delete the
  329. # second device. This results in an error.
  330. #
  331. # This makes use of the fact that the device ID is embedded into the URL.
  332. self.delete_device(
  333. self.user_tok,
  334. "dev2",
  335. HTTPStatus.FORBIDDEN,
  336. {
  337. "auth": {
  338. "type": "m.login.password",
  339. "identifier": {"type": "m.id.user", "user": self.user},
  340. "password": self.user_pass,
  341. "session": session,
  342. },
  343. },
  344. )
  345. @unittest.override_config({"ui_auth": {"session_timeout": "5s"}})
  346. def test_can_reuse_session(self) -> None:
  347. """
  348. The session can be reused if configured.
  349. Compare to test_cannot_change_uri.
  350. """
  351. # Create a second and third login.
  352. self.login("test", self.user_pass, "dev2")
  353. self.login("test", self.user_pass, "dev3")
  354. # Attempt to delete a device. This works since the user just logged in.
  355. self.delete_device(self.user_tok, "dev2", HTTPStatus.OK)
  356. # Move the clock forward past the validation timeout.
  357. self.reactor.advance(6)
  358. # Deleting another devices throws the user into UI auth.
  359. channel = self.delete_device(self.user_tok, "dev3", HTTPStatus.UNAUTHORIZED)
  360. # Grab the session
  361. session = channel.json_body["session"]
  362. # Ensure that flows are what is expected.
  363. self.assertIn({"stages": ["m.login.password"]}, channel.json_body["flows"])
  364. # Make another request providing the UI auth flow.
  365. self.delete_device(
  366. self.user_tok,
  367. "dev3",
  368. HTTPStatus.OK,
  369. {
  370. "auth": {
  371. "type": "m.login.password",
  372. "identifier": {"type": "m.id.user", "user": self.user},
  373. "password": self.user_pass,
  374. "session": session,
  375. },
  376. },
  377. )
  378. # Make another request, but try to delete the first device. This works
  379. # due to re-using the previous session.
  380. #
  381. # Note that *no auth* information is provided, not even a session iD!
  382. self.delete_device(self.user_tok, self.device_id, HTTPStatus.OK)
  383. @skip_unless(HAS_OIDC, "requires OIDC")
  384. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  385. def test_ui_auth_via_sso(self) -> None:
  386. """Test a successful UI Auth flow via SSO
  387. This includes:
  388. * hitting the UIA SSO redirect endpoint
  389. * checking it serves a confirmation page which links to the OIDC provider
  390. * calling back to the synapse oidc callback
  391. * checking that the original operation succeeds
  392. """
  393. fake_oidc_server = self.helper.fake_oidc_server()
  394. # log the user in
  395. remote_user_id = UserID.from_string(self.user).localpart
  396. login_resp, _ = self.helper.login_via_oidc(fake_oidc_server, remote_user_id)
  397. self.assertEqual(login_resp["user_id"], self.user)
  398. # initiate a UI Auth process by attempting to delete the device
  399. channel = self.delete_device(
  400. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  401. )
  402. # check that SSO is offered
  403. flows = channel.json_body["flows"]
  404. self.assertIn({"stages": ["m.login.sso"]}, flows)
  405. # run the UIA-via-SSO flow
  406. session_id = channel.json_body["session"]
  407. channel, _ = self.helper.auth_via_oidc(
  408. fake_oidc_server, {"sub": remote_user_id}, ui_auth_session_id=session_id
  409. )
  410. # that should serve a confirmation page
  411. self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
  412. # and now the delete request should succeed.
  413. self.delete_device(
  414. self.user_tok,
  415. self.device_id,
  416. HTTPStatus.OK,
  417. body={"auth": {"session": session_id}},
  418. )
  419. @skip_unless(HAS_OIDC, "requires OIDC")
  420. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  421. def test_does_not_offer_password_for_sso_user(self) -> None:
  422. fake_oidc_server = self.helper.fake_oidc_server()
  423. login_resp, _ = self.helper.login_via_oidc(fake_oidc_server, "username")
  424. user_tok = login_resp["access_token"]
  425. device_id = login_resp["device_id"]
  426. # now call the device deletion API: we should get the option to auth with SSO
  427. # and not password.
  428. channel = self.delete_device(user_tok, device_id, HTTPStatus.UNAUTHORIZED)
  429. flows = channel.json_body["flows"]
  430. self.assertEqual(flows, [{"stages": ["m.login.sso"]}])
  431. def test_does_not_offer_sso_for_password_user(self) -> None:
  432. channel = self.delete_device(
  433. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  434. )
  435. flows = channel.json_body["flows"]
  436. self.assertEqual(flows, [{"stages": ["m.login.password"]}])
  437. @skip_unless(HAS_OIDC, "requires OIDC")
  438. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  439. def test_offers_both_flows_for_upgraded_user(self) -> None:
  440. """A user that had a password and then logged in with SSO should get both flows"""
  441. fake_oidc_server = self.helper.fake_oidc_server()
  442. login_resp, _ = self.helper.login_via_oidc(
  443. fake_oidc_server, UserID.from_string(self.user).localpart
  444. )
  445. self.assertEqual(login_resp["user_id"], self.user)
  446. channel = self.delete_device(
  447. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  448. )
  449. flows = channel.json_body["flows"]
  450. # we have no particular expectations of ordering here
  451. self.assertIn({"stages": ["m.login.password"]}, flows)
  452. self.assertIn({"stages": ["m.login.sso"]}, flows)
  453. self.assertEqual(len(flows), 2)
  454. @skip_unless(HAS_OIDC, "requires OIDC")
  455. @override_config({"oidc_config": TEST_OIDC_CONFIG})
  456. def test_ui_auth_fails_for_incorrect_sso_user(self) -> None:
  457. """If the user tries to authenticate with the wrong SSO user, they get an error"""
  458. fake_oidc_server = self.helper.fake_oidc_server()
  459. # log the user in
  460. login_resp, _ = self.helper.login_via_oidc(
  461. fake_oidc_server, UserID.from_string(self.user).localpart
  462. )
  463. self.assertEqual(login_resp["user_id"], self.user)
  464. # start a UI Auth flow by attempting to delete a device
  465. channel = self.delete_device(
  466. self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
  467. )
  468. flows = channel.json_body["flows"]
  469. self.assertIn({"stages": ["m.login.sso"]}, flows)
  470. session_id = channel.json_body["session"]
  471. # do the OIDC auth, but auth as the wrong user
  472. channel, _ = self.helper.auth_via_oidc(
  473. fake_oidc_server, {"sub": "wrong_user"}, ui_auth_session_id=session_id
  474. )
  475. # that should return a failure message
  476. self.assertSubstring("We were unable to validate", channel.text_body)
  477. # ... and the delete op should now fail with a 403
  478. self.delete_device(
  479. self.user_tok,
  480. self.device_id,
  481. HTTPStatus.FORBIDDEN,
  482. body={"auth": {"session": session_id}},
  483. )
  484. @skip_unless(HAS_OIDC, "requires OIDC")
  485. @override_config(
  486. {
  487. "oidc_config": TEST_OIDC_CONFIG,
  488. "experimental_features": {
  489. "msc3866": {
  490. "enabled": True,
  491. "require_approval_for_new_accounts": True,
  492. }
  493. },
  494. }
  495. )
  496. def test_sso_not_approved(self) -> None:
  497. """Tests that if we register a user via SSO while requiring approval for new
  498. accounts, we still raise the correct error before logging the user in.
  499. """
  500. fake_oidc_server = self.helper.fake_oidc_server()
  501. login_resp, _ = self.helper.login_via_oidc(
  502. fake_oidc_server, "username", expected_status=403
  503. )
  504. self.assertEqual(login_resp["errcode"], Codes.USER_AWAITING_APPROVAL)
  505. self.assertEqual(
  506. ApprovalNoticeMedium.NONE, login_resp["approval_notice_medium"]
  507. )
  508. # Check that we didn't register a device for the user during the login attempt.
  509. devices = self.get_success(
  510. self.hs.get_datastores().main.get_devices_by_user("@username:test")
  511. )
  512. self.assertEqual(len(devices), 0)
  513. class RefreshAuthTests(unittest.HomeserverTestCase):
  514. servlets = [
  515. auth.register_servlets,
  516. account.register_servlets,
  517. login.register_servlets,
  518. logout.register_servlets,
  519. synapse.rest.admin.register_servlets_for_client_rest_resource,
  520. register.register_servlets,
  521. ]
  522. hijack_auth = False
  523. def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
  524. self.user_pass = "pass"
  525. self.user = self.register_user("test", self.user_pass)
  526. def use_refresh_token(self, refresh_token: str) -> FakeChannel:
  527. """
  528. Helper that makes a request to use a refresh token.
  529. """
  530. return self.make_request(
  531. "POST",
  532. "/_matrix/client/v3/refresh",
  533. {"refresh_token": refresh_token},
  534. )
  535. def test_login_issue_refresh_token(self) -> None:
  536. """
  537. A login response should include a refresh_token only if asked.
  538. """
  539. # Test login
  540. body = {
  541. "type": "m.login.password",
  542. "user": "test",
  543. "password": self.user_pass,
  544. }
  545. login_without_refresh = self.make_request(
  546. "POST", "/_matrix/client/r0/login", body
  547. )
  548. self.assertEqual(
  549. login_without_refresh.code, HTTPStatus.OK, login_without_refresh.result
  550. )
  551. self.assertNotIn("refresh_token", login_without_refresh.json_body)
  552. login_with_refresh = self.make_request(
  553. "POST",
  554. "/_matrix/client/r0/login",
  555. {"refresh_token": True, **body},
  556. )
  557. self.assertEqual(
  558. login_with_refresh.code, HTTPStatus.OK, login_with_refresh.result
  559. )
  560. self.assertIn("refresh_token", login_with_refresh.json_body)
  561. self.assertIn("expires_in_ms", login_with_refresh.json_body)
  562. def test_register_issue_refresh_token(self) -> None:
  563. """
  564. A register response should include a refresh_token only if asked.
  565. """
  566. register_without_refresh = self.make_request(
  567. "POST",
  568. "/_matrix/client/r0/register",
  569. {
  570. "username": "test2",
  571. "password": self.user_pass,
  572. "auth": {"type": LoginType.DUMMY},
  573. },
  574. )
  575. self.assertEqual(
  576. register_without_refresh.code,
  577. HTTPStatus.OK,
  578. register_without_refresh.result,
  579. )
  580. self.assertNotIn("refresh_token", register_without_refresh.json_body)
  581. register_with_refresh = self.make_request(
  582. "POST",
  583. "/_matrix/client/r0/register",
  584. {
  585. "username": "test3",
  586. "password": self.user_pass,
  587. "auth": {"type": LoginType.DUMMY},
  588. "refresh_token": True,
  589. },
  590. )
  591. self.assertEqual(
  592. register_with_refresh.code, HTTPStatus.OK, register_with_refresh.result
  593. )
  594. self.assertIn("refresh_token", register_with_refresh.json_body)
  595. self.assertIn("expires_in_ms", register_with_refresh.json_body)
  596. def test_token_refresh(self) -> None:
  597. """
  598. A refresh token can be used to issue a new access token.
  599. """
  600. body = {
  601. "type": "m.login.password",
  602. "user": "test",
  603. "password": self.user_pass,
  604. "refresh_token": True,
  605. }
  606. login_response = self.make_request(
  607. "POST",
  608. "/_matrix/client/r0/login",
  609. body,
  610. )
  611. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  612. refresh_response = self.make_request(
  613. "POST",
  614. "/_matrix/client/v3/refresh",
  615. {"refresh_token": login_response.json_body["refresh_token"]},
  616. )
  617. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  618. self.assertIn("access_token", refresh_response.json_body)
  619. self.assertIn("refresh_token", refresh_response.json_body)
  620. self.assertIn("expires_in_ms", refresh_response.json_body)
  621. # The access and refresh tokens should be different from the original ones after refresh
  622. self.assertNotEqual(
  623. login_response.json_body["access_token"],
  624. refresh_response.json_body["access_token"],
  625. )
  626. self.assertNotEqual(
  627. login_response.json_body["refresh_token"],
  628. refresh_response.json_body["refresh_token"],
  629. )
  630. @override_config({"refreshable_access_token_lifetime": "1m"})
  631. def test_refreshable_access_token_expiration(self) -> None:
  632. """
  633. The access token should have some time as specified in the config.
  634. """
  635. body = {
  636. "type": "m.login.password",
  637. "user": "test",
  638. "password": self.user_pass,
  639. "refresh_token": True,
  640. }
  641. login_response = self.make_request(
  642. "POST",
  643. "/_matrix/client/r0/login",
  644. body,
  645. )
  646. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  647. self.assertApproximates(
  648. login_response.json_body["expires_in_ms"], 60 * 1000, 100
  649. )
  650. refresh_response = self.make_request(
  651. "POST",
  652. "/_matrix/client/v3/refresh",
  653. {"refresh_token": login_response.json_body["refresh_token"]},
  654. )
  655. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  656. self.assertApproximates(
  657. refresh_response.json_body["expires_in_ms"], 60 * 1000, 100
  658. )
  659. access_token = refresh_response.json_body["access_token"]
  660. # Advance 59 seconds in the future (just shy of 1 minute, the time of expiry)
  661. self.reactor.advance(59.0)
  662. # Check that our token is valid
  663. self.assertEqual(
  664. self.make_request(
  665. "GET", "/_matrix/client/v3/account/whoami", access_token=access_token
  666. ).code,
  667. HTTPStatus.OK,
  668. )
  669. # Advance 2 more seconds (just past the time of expiry)
  670. self.reactor.advance(2.0)
  671. # Check that our token is invalid
  672. self.assertEqual(
  673. self.make_request(
  674. "GET", "/_matrix/client/v3/account/whoami", access_token=access_token
  675. ).code,
  676. HTTPStatus.UNAUTHORIZED,
  677. )
  678. @override_config(
  679. {
  680. "refreshable_access_token_lifetime": "1m",
  681. "nonrefreshable_access_token_lifetime": "10m",
  682. }
  683. )
  684. def test_different_expiry_for_refreshable_and_nonrefreshable_access_tokens(
  685. self,
  686. ) -> None:
  687. """
  688. Tests that the expiry times for refreshable and non-refreshable access
  689. tokens can be different.
  690. """
  691. body = {
  692. "type": "m.login.password",
  693. "user": "test",
  694. "password": self.user_pass,
  695. }
  696. login_response1 = self.make_request(
  697. "POST",
  698. "/_matrix/client/r0/login",
  699. {"refresh_token": True, **body},
  700. )
  701. self.assertEqual(login_response1.code, HTTPStatus.OK, login_response1.result)
  702. self.assertApproximates(
  703. login_response1.json_body["expires_in_ms"], 60 * 1000, 100
  704. )
  705. refreshable_access_token = login_response1.json_body["access_token"]
  706. login_response2 = self.make_request(
  707. "POST",
  708. "/_matrix/client/r0/login",
  709. body,
  710. )
  711. self.assertEqual(login_response2.code, HTTPStatus.OK, login_response2.result)
  712. nonrefreshable_access_token = login_response2.json_body["access_token"]
  713. # Advance 59 seconds in the future (just shy of 1 minute, the time of expiry)
  714. self.reactor.advance(59.0)
  715. # Both tokens should still be valid.
  716. self.helper.whoami(refreshable_access_token, expect_code=HTTPStatus.OK)
  717. self.helper.whoami(nonrefreshable_access_token, expect_code=HTTPStatus.OK)
  718. # Advance to 61 s (just past 1 minute, the time of expiry)
  719. self.reactor.advance(2.0)
  720. # Only the non-refreshable token is still valid.
  721. self.helper.whoami(
  722. refreshable_access_token, expect_code=HTTPStatus.UNAUTHORIZED
  723. )
  724. self.helper.whoami(nonrefreshable_access_token, expect_code=HTTPStatus.OK)
  725. # Advance to 599 s (just shy of 10 minutes, the time of expiry)
  726. self.reactor.advance(599.0 - 61.0)
  727. # It's still the case that only the non-refreshable token is still valid.
  728. self.helper.whoami(
  729. refreshable_access_token, expect_code=HTTPStatus.UNAUTHORIZED
  730. )
  731. self.helper.whoami(nonrefreshable_access_token, expect_code=HTTPStatus.OK)
  732. # Advance to 601 s (just past 10 minutes, the time of expiry)
  733. self.reactor.advance(2.0)
  734. # Now neither token is valid.
  735. self.helper.whoami(
  736. refreshable_access_token, expect_code=HTTPStatus.UNAUTHORIZED
  737. )
  738. self.helper.whoami(
  739. nonrefreshable_access_token, expect_code=HTTPStatus.UNAUTHORIZED
  740. )
  741. @override_config(
  742. {"refreshable_access_token_lifetime": "1m", "refresh_token_lifetime": "2m"}
  743. )
  744. def test_refresh_token_expiry(self) -> None:
  745. """
  746. The refresh token can be configured to have a limited lifetime.
  747. When that lifetime has ended, the refresh token can no longer be used to
  748. refresh the session.
  749. """
  750. body = {
  751. "type": "m.login.password",
  752. "user": "test",
  753. "password": self.user_pass,
  754. "refresh_token": True,
  755. }
  756. login_response = self.make_request(
  757. "POST",
  758. "/_matrix/client/r0/login",
  759. body,
  760. )
  761. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  762. refresh_token1 = login_response.json_body["refresh_token"]
  763. # Advance 119 seconds in the future (just shy of 2 minutes)
  764. self.reactor.advance(119.0)
  765. # Refresh our session. The refresh token should still JUST be valid right now.
  766. # By doing so, we get a new access token and a new refresh token.
  767. refresh_response = self.use_refresh_token(refresh_token1)
  768. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  769. self.assertIn(
  770. "refresh_token",
  771. refresh_response.json_body,
  772. "No new refresh token returned after refresh.",
  773. )
  774. refresh_token2 = refresh_response.json_body["refresh_token"]
  775. # Advance 121 seconds in the future (just a bit more than 2 minutes)
  776. self.reactor.advance(121.0)
  777. # Try to refresh our session, but instead notice that the refresh token is
  778. # not valid (it just expired).
  779. refresh_response = self.use_refresh_token(refresh_token2)
  780. self.assertEqual(
  781. refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result
  782. )
  783. @override_config(
  784. {
  785. "refreshable_access_token_lifetime": "2m",
  786. "refresh_token_lifetime": "2m",
  787. "session_lifetime": "3m",
  788. }
  789. )
  790. def test_ultimate_session_expiry(self) -> None:
  791. """
  792. The session can be configured to have an ultimate, limited lifetime.
  793. """
  794. body = {
  795. "type": "m.login.password",
  796. "user": "test",
  797. "password": self.user_pass,
  798. "refresh_token": True,
  799. }
  800. login_response = self.make_request(
  801. "POST",
  802. "/_matrix/client/r0/login",
  803. body,
  804. )
  805. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  806. refresh_token = login_response.json_body["refresh_token"]
  807. # Advance shy of 2 minutes into the future
  808. self.reactor.advance(119.0)
  809. # Refresh our session. The refresh token should still be valid right now.
  810. refresh_response = self.use_refresh_token(refresh_token)
  811. self.assertEqual(refresh_response.code, HTTPStatus.OK, refresh_response.result)
  812. self.assertIn(
  813. "refresh_token",
  814. refresh_response.json_body,
  815. "No new refresh token returned after refresh.",
  816. )
  817. # Notice that our access token lifetime has been diminished to match the
  818. # session lifetime.
  819. # 3 minutes - 119 seconds = 61 seconds.
  820. self.assertEqual(refresh_response.json_body["expires_in_ms"], 61_000)
  821. refresh_token = refresh_response.json_body["refresh_token"]
  822. # Advance 61 seconds into the future. Our session should have expired
  823. # now, because we've had our 3 minutes.
  824. self.reactor.advance(61.0)
  825. # Try to issue a new, refreshed, access token.
  826. # This should fail because the refresh token's lifetime has also been
  827. # diminished as our session expired.
  828. refresh_response = self.use_refresh_token(refresh_token)
  829. self.assertEqual(
  830. refresh_response.code, HTTPStatus.FORBIDDEN, refresh_response.result
  831. )
  832. def test_refresh_token_invalidation(self) -> None:
  833. """Refresh tokens are invalidated after first use of the next token.
  834. A refresh token is considered invalid if:
  835. - it was already used at least once
  836. - and either
  837. - the next access token was used
  838. - the next refresh token was used
  839. The chain of tokens goes like this:
  840. login -|-> first_refresh -> third_refresh (fails)
  841. |-> second_refresh -> fifth_refresh
  842. |-> fourth_refresh (fails)
  843. """
  844. body = {
  845. "type": "m.login.password",
  846. "user": "test",
  847. "password": self.user_pass,
  848. "refresh_token": True,
  849. }
  850. login_response = self.make_request(
  851. "POST",
  852. "/_matrix/client/r0/login",
  853. body,
  854. )
  855. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  856. # This first refresh should work properly
  857. first_refresh_response = self.make_request(
  858. "POST",
  859. "/_matrix/client/v3/refresh",
  860. {"refresh_token": login_response.json_body["refresh_token"]},
  861. )
  862. self.assertEqual(
  863. first_refresh_response.code, HTTPStatus.OK, first_refresh_response.result
  864. )
  865. # This one as well, since the token in the first one was never used
  866. second_refresh_response = self.make_request(
  867. "POST",
  868. "/_matrix/client/v3/refresh",
  869. {"refresh_token": login_response.json_body["refresh_token"]},
  870. )
  871. self.assertEqual(
  872. second_refresh_response.code, HTTPStatus.OK, second_refresh_response.result
  873. )
  874. # This one should not, since the token from the first refresh is not valid anymore
  875. third_refresh_response = self.make_request(
  876. "POST",
  877. "/_matrix/client/v3/refresh",
  878. {"refresh_token": first_refresh_response.json_body["refresh_token"]},
  879. )
  880. self.assertEqual(
  881. third_refresh_response.code,
  882. HTTPStatus.UNAUTHORIZED,
  883. third_refresh_response.result,
  884. )
  885. # The associated access token should also be invalid
  886. whoami_response = self.make_request(
  887. "GET",
  888. "/_matrix/client/r0/account/whoami",
  889. access_token=first_refresh_response.json_body["access_token"],
  890. )
  891. self.assertEqual(
  892. whoami_response.code, HTTPStatus.UNAUTHORIZED, whoami_response.result
  893. )
  894. # But all other tokens should work (they will expire after some time)
  895. for access_token in [
  896. second_refresh_response.json_body["access_token"],
  897. login_response.json_body["access_token"],
  898. ]:
  899. whoami_response = self.make_request(
  900. "GET", "/_matrix/client/r0/account/whoami", access_token=access_token
  901. )
  902. self.assertEqual(
  903. whoami_response.code, HTTPStatus.OK, whoami_response.result
  904. )
  905. # Now that the access token from the last valid refresh was used once, refreshing with the N-1 token should fail
  906. fourth_refresh_response = self.make_request(
  907. "POST",
  908. "/_matrix/client/v3/refresh",
  909. {"refresh_token": login_response.json_body["refresh_token"]},
  910. )
  911. self.assertEqual(
  912. fourth_refresh_response.code,
  913. HTTPStatus.FORBIDDEN,
  914. fourth_refresh_response.result,
  915. )
  916. # But refreshing from the last valid refresh token still works
  917. fifth_refresh_response = self.make_request(
  918. "POST",
  919. "/_matrix/client/v3/refresh",
  920. {"refresh_token": second_refresh_response.json_body["refresh_token"]},
  921. )
  922. self.assertEqual(
  923. fifth_refresh_response.code, HTTPStatus.OK, fifth_refresh_response.result
  924. )
  925. def test_many_token_refresh(self) -> None:
  926. """
  927. If a refresh is performed many times during a session, there shouldn't be
  928. extra 'cruft' built up over time.
  929. This test was written specifically to troubleshoot a case where logout
  930. was very slow if a lot of refreshes had been performed for the session.
  931. """
  932. def _refresh(refresh_token: str) -> Tuple[str, str]:
  933. """
  934. Performs one refresh, returning the next refresh token and access token.
  935. """
  936. refresh_response = self.use_refresh_token(refresh_token)
  937. self.assertEqual(
  938. refresh_response.code, HTTPStatus.OK, refresh_response.result
  939. )
  940. return (
  941. refresh_response.json_body["refresh_token"],
  942. refresh_response.json_body["access_token"],
  943. )
  944. def _table_length(table_name: str) -> int:
  945. """
  946. Helper to get the size of a table, in rows.
  947. For testing only; trivially vulnerable to SQL injection.
  948. """
  949. def _txn(txn: LoggingTransaction) -> int:
  950. txn.execute(f"SELECT COUNT(1) FROM {table_name}")
  951. row = txn.fetchone()
  952. # Query is infallible
  953. assert row is not None
  954. return row[0]
  955. return self.get_success(
  956. self.hs.get_datastores().main.db_pool.runInteraction(
  957. "_table_length", _txn
  958. )
  959. )
  960. # Before we log in, there are no access tokens.
  961. self.assertEqual(_table_length("access_tokens"), 0)
  962. self.assertEqual(_table_length("refresh_tokens"), 0)
  963. body = {
  964. "type": "m.login.password",
  965. "user": "test",
  966. "password": self.user_pass,
  967. "refresh_token": True,
  968. }
  969. login_response = self.make_request(
  970. "POST",
  971. "/_matrix/client/v3/login",
  972. body,
  973. )
  974. self.assertEqual(login_response.code, HTTPStatus.OK, login_response.result)
  975. access_token = login_response.json_body["access_token"]
  976. refresh_token = login_response.json_body["refresh_token"]
  977. # Now that we have logged in, there should be one access token and one
  978. # refresh token
  979. self.assertEqual(_table_length("access_tokens"), 1)
  980. self.assertEqual(_table_length("refresh_tokens"), 1)
  981. for _ in range(5):
  982. refresh_token, access_token = _refresh(refresh_token)
  983. # After 5 sequential refreshes, there should only be the latest two
  984. # refresh/access token pairs.
  985. # (The last one is preserved because it's in use!
  986. # The one before that is preserved because it can still be used to
  987. # replace the last token pair, in case of e.g. a network interruption.)
  988. self.assertEqual(_table_length("access_tokens"), 2)
  989. self.assertEqual(_table_length("refresh_tokens"), 2)
  990. logout_response = self.make_request(
  991. "POST", "/_matrix/client/v3/logout", {}, access_token=access_token
  992. )
  993. self.assertEqual(logout_response.code, HTTPStatus.OK, logout_response.result)
  994. # Now that we have logged in, there should be no access token
  995. # and no refresh token
  996. self.assertEqual(_table_length("access_tokens"), 0)
  997. self.assertEqual(_table_length("refresh_tokens"), 0)
  998. def oidc_config(
  999. id: str, with_localpart_template: bool, **kwargs: Any
  1000. ) -> Dict[str, Any]:
  1001. """Sample OIDC provider config used in backchannel logout tests.
  1002. Args:
  1003. id: IDP ID for this provider
  1004. with_localpart_template: Set to `true` to have a default localpart_template in
  1005. the `user_mapping_provider` config and skip the user mapping session
  1006. **kwargs: rest of the config
  1007. Returns:
  1008. A dict suitable for the `oidc_config` or the `oidc_providers[]` parts of
  1009. the HS config
  1010. """
  1011. config: Dict[str, Any] = {
  1012. "idp_id": id,
  1013. "idp_name": id,
  1014. "issuer": TEST_OIDC_ISSUER,
  1015. "client_id": "test-client-id",
  1016. "client_secret": "test-client-secret",
  1017. "scopes": ["openid"],
  1018. }
  1019. if with_localpart_template:
  1020. config["user_mapping_provider"] = {
  1021. "config": {"localpart_template": "{{ user.sub }}"}
  1022. }
  1023. else:
  1024. config["user_mapping_provider"] = {"config": {}}
  1025. config.update(kwargs)
  1026. return config
  1027. @skip_unless(HAS_OIDC, "Requires OIDC")
  1028. class OidcBackchannelLogoutTests(unittest.HomeserverTestCase):
  1029. servlets = [
  1030. account.register_servlets,
  1031. login.register_servlets,
  1032. ]
  1033. def default_config(self) -> Dict[str, Any]:
  1034. config = super().default_config()
  1035. # public_baseurl uses an http:// scheme because FakeChannel.isSecure() returns
  1036. # False, so synapse will see the requested uri as http://..., so using http in
  1037. # the public_baseurl stops Synapse trying to redirect to https.
  1038. config["public_baseurl"] = "http://synapse.test"
  1039. return config
  1040. def create_resource_dict(self) -> Dict[str, Resource]:
  1041. resource_dict = super().create_resource_dict()
  1042. resource_dict.update(build_synapse_client_resource_tree(self.hs))
  1043. return resource_dict
  1044. def submit_logout_token(self, logout_token: str) -> FakeChannel:
  1045. return self.make_request(
  1046. "POST",
  1047. "/_synapse/client/oidc/backchannel_logout",
  1048. content=f"logout_token={logout_token}",
  1049. content_is_form=True,
  1050. )
  1051. @override_config(
  1052. {
  1053. "oidc_providers": [
  1054. oidc_config(
  1055. id="oidc",
  1056. with_localpart_template=True,
  1057. backchannel_logout_enabled=True,
  1058. )
  1059. ]
  1060. }
  1061. )
  1062. def test_simple_logout(self) -> None:
  1063. """
  1064. Receiving a logout token should logout the user
  1065. """
  1066. fake_oidc_server = self.helper.fake_oidc_server()
  1067. user = "john"
  1068. login_resp, first_grant = self.helper.login_via_oidc(
  1069. fake_oidc_server, user, with_sid=True
  1070. )
  1071. first_access_token: str = login_resp["access_token"]
  1072. self.helper.whoami(first_access_token, expect_code=HTTPStatus.OK)
  1073. login_resp, second_grant = self.helper.login_via_oidc(
  1074. fake_oidc_server, user, with_sid=True
  1075. )
  1076. second_access_token: str = login_resp["access_token"]
  1077. self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)
  1078. self.assertNotEqual(first_grant.sid, second_grant.sid)
  1079. self.assertEqual(first_grant.userinfo["sub"], second_grant.userinfo["sub"])
  1080. # Logging out of the first session
  1081. logout_token = fake_oidc_server.generate_logout_token(first_grant)
  1082. channel = self.submit_logout_token(logout_token)
  1083. self.assertEqual(channel.code, 200)
  1084. self.helper.whoami(first_access_token, expect_code=HTTPStatus.UNAUTHORIZED)
  1085. self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)
  1086. # Logging out of the second session
  1087. logout_token = fake_oidc_server.generate_logout_token(second_grant)
  1088. channel = self.submit_logout_token(logout_token)
  1089. self.assertEqual(channel.code, 200)
  1090. @override_config(
  1091. {
  1092. "oidc_providers": [
  1093. oidc_config(
  1094. id="oidc",
  1095. with_localpart_template=True,
  1096. backchannel_logout_enabled=True,
  1097. )
  1098. ]
  1099. }
  1100. )
  1101. def test_logout_during_login(self) -> None:
  1102. """
  1103. It should revoke login tokens when receiving a logout token
  1104. """
  1105. fake_oidc_server = self.helper.fake_oidc_server()
  1106. user = "john"
  1107. # Get an authentication, and logout before submitting the logout token
  1108. client_redirect_url = "https://x"
  1109. userinfo = {"sub": user}
  1110. channel, grant = self.helper.auth_via_oidc(
  1111. fake_oidc_server,
  1112. userinfo,
  1113. client_redirect_url,
  1114. with_sid=True,
  1115. )
  1116. # expect a confirmation page
  1117. self.assertEqual(channel.code, HTTPStatus.OK, channel.result)
  1118. # fish the matrix login token out of the body of the confirmation page
  1119. m = re.search(
  1120. 'a href="%s.*loginToken=([^"]*)"' % (client_redirect_url,),
  1121. channel.text_body,
  1122. )
  1123. assert m, channel.text_body
  1124. login_token = m.group(1)
  1125. # Submit a logout
  1126. logout_token = fake_oidc_server.generate_logout_token(grant)
  1127. channel = self.submit_logout_token(logout_token)
  1128. self.assertEqual(channel.code, 200)
  1129. # Now try to exchange the login token, it should fail.
  1130. self.helper.login_via_token(login_token, 403)
  1131. @override_config(
  1132. {
  1133. "oidc_providers": [
  1134. oidc_config(
  1135. id="oidc",
  1136. with_localpart_template=False,
  1137. backchannel_logout_enabled=True,
  1138. )
  1139. ]
  1140. }
  1141. )
  1142. def test_logout_during_mapping(self) -> None:
  1143. """
  1144. It should stop ongoing user mapping session when receiving a logout token
  1145. """
  1146. fake_oidc_server = self.helper.fake_oidc_server()
  1147. user = "john"
  1148. # Get an authentication, and logout before submitting the logout token
  1149. client_redirect_url = "https://x"
  1150. userinfo = {"sub": user}
  1151. channel, grant = self.helper.auth_via_oidc(
  1152. fake_oidc_server,
  1153. userinfo,
  1154. client_redirect_url,
  1155. with_sid=True,
  1156. )
  1157. # Expect a user mapping page
  1158. self.assertEqual(channel.code, HTTPStatus.FOUND, channel.result)
  1159. # We should have a user_mapping_session cookie
  1160. cookie_headers = channel.headers.getRawHeaders("Set-Cookie")
  1161. assert cookie_headers
  1162. cookies: Dict[str, str] = {}
  1163. for h in cookie_headers:
  1164. key, value = h.split(";")[0].split("=", maxsplit=1)
  1165. cookies[key] = value
  1166. user_mapping_session_id = cookies["username_mapping_session"]
  1167. # Getting that session should not raise
  1168. session = self.hs.get_sso_handler().get_mapping_session(user_mapping_session_id)
  1169. self.assertIsNotNone(session)
  1170. # Submit a logout
  1171. logout_token = fake_oidc_server.generate_logout_token(grant)
  1172. channel = self.submit_logout_token(logout_token)
  1173. self.assertEqual(channel.code, 200)
  1174. # Now it should raise
  1175. with self.assertRaises(SynapseError):
  1176. self.hs.get_sso_handler().get_mapping_session(user_mapping_session_id)
  1177. @override_config(
  1178. {
  1179. "oidc_providers": [
  1180. oidc_config(
  1181. id="oidc",
  1182. with_localpart_template=True,
  1183. backchannel_logout_enabled=False,
  1184. )
  1185. ]
  1186. }
  1187. )
  1188. def test_disabled(self) -> None:
  1189. """
  1190. Receiving a logout token should do nothing if it is disabled in the config
  1191. """
  1192. fake_oidc_server = self.helper.fake_oidc_server()
  1193. user = "john"
  1194. login_resp, grant = self.helper.login_via_oidc(
  1195. fake_oidc_server, user, with_sid=True
  1196. )
  1197. access_token: str = login_resp["access_token"]
  1198. self.helper.whoami(access_token, expect_code=HTTPStatus.OK)
  1199. # Logging out shouldn't work
  1200. logout_token = fake_oidc_server.generate_logout_token(grant)
  1201. channel = self.submit_logout_token(logout_token)
  1202. self.assertEqual(channel.code, 400)
  1203. # And the token should still be valid
  1204. self.helper.whoami(access_token, expect_code=HTTPStatus.OK)
  1205. @override_config(
  1206. {
  1207. "oidc_providers": [
  1208. oidc_config(
  1209. id="oidc",
  1210. with_localpart_template=True,
  1211. backchannel_logout_enabled=True,
  1212. )
  1213. ]
  1214. }
  1215. )
  1216. def test_no_sid(self) -> None:
  1217. """
  1218. Receiving a logout token without `sid` during the login should do nothing
  1219. """
  1220. fake_oidc_server = self.helper.fake_oidc_server()
  1221. user = "john"
  1222. login_resp, grant = self.helper.login_via_oidc(
  1223. fake_oidc_server, user, with_sid=False
  1224. )
  1225. access_token: str = login_resp["access_token"]
  1226. self.helper.whoami(access_token, expect_code=HTTPStatus.OK)
  1227. # Logging out shouldn't work
  1228. logout_token = fake_oidc_server.generate_logout_token(grant)
  1229. channel = self.submit_logout_token(logout_token)
  1230. self.assertEqual(channel.code, 400)
  1231. # And the token should still be valid
  1232. self.helper.whoami(access_token, expect_code=HTTPStatus.OK)
  1233. @override_config(
  1234. {
  1235. "oidc_providers": [
  1236. oidc_config(
  1237. "first",
  1238. issuer="https://first-issuer.com/",
  1239. with_localpart_template=True,
  1240. backchannel_logout_enabled=True,
  1241. ),
  1242. oidc_config(
  1243. "second",
  1244. issuer="https://second-issuer.com/",
  1245. with_localpart_template=True,
  1246. backchannel_logout_enabled=True,
  1247. ),
  1248. ]
  1249. }
  1250. )
  1251. def test_multiple_providers(self) -> None:
  1252. """
  1253. It should be able to distinguish login tokens from two different IdPs
  1254. """
  1255. first_server = self.helper.fake_oidc_server(issuer="https://first-issuer.com/")
  1256. second_server = self.helper.fake_oidc_server(
  1257. issuer="https://second-issuer.com/"
  1258. )
  1259. user = "john"
  1260. login_resp, first_grant = self.helper.login_via_oidc(
  1261. first_server, user, with_sid=True, idp_id="oidc-first"
  1262. )
  1263. first_access_token: str = login_resp["access_token"]
  1264. self.helper.whoami(first_access_token, expect_code=HTTPStatus.OK)
  1265. login_resp, second_grant = self.helper.login_via_oidc(
  1266. second_server, user, with_sid=True, idp_id="oidc-second"
  1267. )
  1268. second_access_token: str = login_resp["access_token"]
  1269. self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)
  1270. # `sid` in the fake providers are generated by a counter, so the first grant of
  1271. # each provider should give the same SID
  1272. self.assertEqual(first_grant.sid, second_grant.sid)
  1273. self.assertEqual(first_grant.userinfo["sub"], second_grant.userinfo["sub"])
  1274. # Logging out of the first session
  1275. logout_token = first_server.generate_logout_token(first_grant)
  1276. channel = self.submit_logout_token(logout_token)
  1277. self.assertEqual(channel.code, 200)
  1278. self.helper.whoami(first_access_token, expect_code=HTTPStatus.UNAUTHORIZED)
  1279. self.helper.whoami(second_access_token, expect_code=HTTPStatus.OK)
  1280. # Logging out of the second session
  1281. logout_token = second_server.generate_logout_token(second_grant)
  1282. channel = self.submit_logout_token(logout_token)
  1283. self.assertEqual(channel.code, 200)
  1284. self.helper.whoami(second_access_token, expect_code=HTTPStatus.UNAUTHORIZED)