test_auth.py 54 KB

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