test_oidc.py 47 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247
  1. # Copyright 2020 Quentin Gliech
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. import os
  15. from typing import Any, Awaitable, ContextManager, Dict, Optional, Tuple
  16. from unittest.mock import ANY, Mock, patch
  17. from urllib.parse import parse_qs, urlparse
  18. import pymacaroons
  19. from twisted.test.proto_helpers import MemoryReactor
  20. from synapse.handlers.sso import MappingException
  21. from synapse.http.site import SynapseRequest
  22. from synapse.server import HomeServer
  23. from synapse.types import JsonDict, UserID
  24. from synapse.util import Clock
  25. from synapse.util.macaroons import get_value_from_macaroon
  26. from synapse.util.stringutils import random_string
  27. from tests.test_utils import FakeResponse, get_awaitable_result, simple_async_mock
  28. from tests.test_utils.oidc import FakeAuthorizationGrant, FakeOidcServer
  29. from tests.unittest import HomeserverTestCase, override_config
  30. try:
  31. import authlib # noqa: F401
  32. from authlib.oidc.core import UserInfo
  33. from authlib.oidc.discovery import OpenIDProviderMetadata
  34. from synapse.handlers.oidc import Token, UserAttributeDict
  35. HAS_OIDC = True
  36. except ImportError:
  37. HAS_OIDC = False
  38. # These are a few constants that are used as config parameters in the tests.
  39. ISSUER = "https://issuer/"
  40. CLIENT_ID = "test-client-id"
  41. CLIENT_SECRET = "test-client-secret"
  42. BASE_URL = "https://synapse/"
  43. CALLBACK_URL = BASE_URL + "_synapse/client/oidc/callback"
  44. SCOPES = ["openid"]
  45. # config for common cases
  46. DEFAULT_CONFIG = {
  47. "enabled": True,
  48. "client_id": CLIENT_ID,
  49. "client_secret": CLIENT_SECRET,
  50. "issuer": ISSUER,
  51. "scopes": SCOPES,
  52. "user_mapping_provider": {"module": __name__ + ".TestMappingProvider"},
  53. }
  54. # extends the default config with explicit OAuth2 endpoints instead of using discovery
  55. EXPLICIT_ENDPOINT_CONFIG = {
  56. **DEFAULT_CONFIG,
  57. "discover": False,
  58. "authorization_endpoint": ISSUER + "authorize",
  59. "token_endpoint": ISSUER + "token",
  60. "jwks_uri": ISSUER + "jwks",
  61. }
  62. class TestMappingProvider:
  63. @staticmethod
  64. def parse_config(config: JsonDict) -> None:
  65. return None
  66. def __init__(self, config: None):
  67. pass
  68. def get_remote_user_id(self, userinfo: "UserInfo") -> str:
  69. return userinfo["sub"]
  70. async def map_user_attributes(
  71. self, userinfo: "UserInfo", token: "Token"
  72. ) -> "UserAttributeDict":
  73. # This is testing not providing the full map.
  74. return {"localpart": userinfo["username"], "display_name": None} # type: ignore[typeddict-item]
  75. # Do not include get_extra_attributes to test backwards compatibility paths.
  76. class TestMappingProviderExtra(TestMappingProvider):
  77. async def get_extra_attributes(
  78. self, userinfo: "UserInfo", token: "Token"
  79. ) -> JsonDict:
  80. return {"phone": userinfo["phone"]}
  81. class TestMappingProviderFailures(TestMappingProvider):
  82. # Superclass is testing the legacy interface for map_user_attributes.
  83. async def map_user_attributes( # type: ignore[override]
  84. self, userinfo: "UserInfo", token: "Token", failures: int
  85. ) -> "UserAttributeDict":
  86. return { # type: ignore[typeddict-item]
  87. "localpart": userinfo["username"] + (str(failures) if failures else ""),
  88. "display_name": None,
  89. }
  90. def _key_file_path() -> str:
  91. """path to a file containing the private half of a test key"""
  92. # this key was generated with:
  93. # openssl ecparam -name prime256v1 -genkey -noout |
  94. # openssl pkcs8 -topk8 -nocrypt -out oidc_test_key.p8
  95. #
  96. # we use PKCS8 rather than SEC-1 (which is what openssl ecparam spits out), because
  97. # that's what Apple use, and we want to be sure that we work with Apple's keys.
  98. #
  99. # (For the record: both PKCS8 and SEC-1 specify (different) ways of representing
  100. # keys using ASN.1. Both are then typically formatted using PEM, which says: use the
  101. # base64-encoded DER encoding of ASN.1, with headers and footers. But we don't
  102. # really need to care about any of that.)
  103. return os.path.join(os.path.dirname(__file__), "oidc_test_key.p8")
  104. def _public_key_file_path() -> str:
  105. """path to a file containing the public half of a test key"""
  106. # this was generated with:
  107. # openssl ec -in oidc_test_key.p8 -pubout -out oidc_test_key.pub.pem
  108. #
  109. # See above about where oidc_test_key.p8 came from
  110. return os.path.join(os.path.dirname(__file__), "oidc_test_key.pub.pem")
  111. class OidcHandlerTestCase(HomeserverTestCase):
  112. if not HAS_OIDC:
  113. skip = "requires OIDC"
  114. def default_config(self) -> Dict[str, Any]:
  115. config = super().default_config()
  116. config["public_baseurl"] = BASE_URL
  117. return config
  118. def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
  119. self.fake_server = FakeOidcServer(clock=clock, issuer=ISSUER)
  120. hs = self.setup_test_homeserver()
  121. self.hs_patcher = self.fake_server.patch_homeserver(hs=hs)
  122. self.hs_patcher.start()
  123. self.handler = hs.get_oidc_handler()
  124. self.provider = self.handler._providers["oidc"]
  125. sso_handler = hs.get_sso_handler()
  126. # Mock the render error method.
  127. self.render_error = Mock(return_value=None)
  128. sso_handler.render_error = self.render_error # type: ignore[assignment]
  129. # Reduce the number of attempts when generating MXIDs.
  130. sso_handler._MAP_USERNAME_RETRIES = 3
  131. auth_handler = hs.get_auth_handler()
  132. # Mock the complete SSO login method.
  133. self.complete_sso_login = simple_async_mock()
  134. auth_handler.complete_sso_login = self.complete_sso_login # type: ignore[assignment]
  135. return hs
  136. def tearDown(self) -> None:
  137. self.hs_patcher.stop()
  138. return super().tearDown()
  139. def reset_mocks(self) -> None:
  140. """Reset all the Mocks."""
  141. self.fake_server.reset_mocks()
  142. self.render_error.reset_mock()
  143. self.complete_sso_login.reset_mock()
  144. def metadata_edit(self, values: dict) -> ContextManager[Mock]:
  145. """Modify the result that will be returned by the well-known query"""
  146. metadata = self.fake_server.get_metadata()
  147. metadata.update(values)
  148. return patch.object(self.fake_server, "get_metadata", return_value=metadata)
  149. def start_authorization(
  150. self,
  151. userinfo: dict,
  152. client_redirect_url: str = "http://client/redirect",
  153. scope: str = "openid",
  154. with_sid: bool = False,
  155. ) -> Tuple[SynapseRequest, FakeAuthorizationGrant]:
  156. """Start an authorization request, and get the callback request back."""
  157. nonce = random_string(10)
  158. state = random_string(10)
  159. code, grant = self.fake_server.start_authorization(
  160. userinfo=userinfo,
  161. scope=scope,
  162. client_id=self.provider._client_auth.client_id,
  163. redirect_uri=self.provider._callback_url,
  164. nonce=nonce,
  165. with_sid=with_sid,
  166. )
  167. session = self._generate_oidc_session_token(state, nonce, client_redirect_url)
  168. return _build_callback_request(code, state, session), grant
  169. def assertRenderedError(
  170. self, error: str, error_description: Optional[str] = None
  171. ) -> Tuple[Any, ...]:
  172. self.render_error.assert_called_once()
  173. args = self.render_error.call_args[0]
  174. self.assertEqual(args[1], error)
  175. if error_description is not None:
  176. self.assertEqual(args[2], error_description)
  177. # Reset the render_error mock
  178. self.render_error.reset_mock()
  179. return args
  180. @override_config({"oidc_config": DEFAULT_CONFIG})
  181. def test_config(self) -> None:
  182. """Basic config correctly sets up the callback URL and client auth correctly."""
  183. self.assertEqual(self.provider._callback_url, CALLBACK_URL)
  184. self.assertEqual(self.provider._client_auth.client_id, CLIENT_ID)
  185. self.assertEqual(self.provider._client_auth.client_secret, CLIENT_SECRET)
  186. @override_config({"oidc_config": {**DEFAULT_CONFIG, "discover": True}})
  187. def test_discovery(self) -> None:
  188. """The handler should discover the endpoints from OIDC discovery document."""
  189. # This would throw if some metadata were invalid
  190. metadata = self.get_success(self.provider.load_metadata())
  191. self.fake_server.get_metadata_handler.assert_called_once()
  192. self.assertEqual(metadata.issuer, self.fake_server.issuer)
  193. self.assertEqual(
  194. metadata.authorization_endpoint,
  195. self.fake_server.authorization_endpoint,
  196. )
  197. self.assertEqual(metadata.token_endpoint, self.fake_server.token_endpoint)
  198. self.assertEqual(metadata.jwks_uri, self.fake_server.jwks_uri)
  199. # It seems like authlib does not have that defined in its metadata models
  200. self.assertEqual(
  201. metadata.get("userinfo_endpoint"),
  202. self.fake_server.userinfo_endpoint,
  203. )
  204. # subsequent calls should be cached
  205. self.reset_mocks()
  206. self.get_success(self.provider.load_metadata())
  207. self.fake_server.get_metadata_handler.assert_not_called()
  208. @override_config({"oidc_config": EXPLICIT_ENDPOINT_CONFIG})
  209. def test_no_discovery(self) -> None:
  210. """When discovery is disabled, it should not try to load from discovery document."""
  211. self.get_success(self.provider.load_metadata())
  212. self.fake_server.get_metadata_handler.assert_not_called()
  213. @override_config({"oidc_config": DEFAULT_CONFIG})
  214. def test_load_jwks(self) -> None:
  215. """JWKS loading is done once (then cached) if used."""
  216. jwks = self.get_success(self.provider.load_jwks())
  217. self.fake_server.get_jwks_handler.assert_called_once()
  218. self.assertEqual(jwks, self.fake_server.get_jwks())
  219. # subsequent calls should be cached…
  220. self.reset_mocks()
  221. self.get_success(self.provider.load_jwks())
  222. self.fake_server.get_jwks_handler.assert_not_called()
  223. # …unless forced
  224. self.reset_mocks()
  225. self.get_success(self.provider.load_jwks(force=True))
  226. self.fake_server.get_jwks_handler.assert_called_once()
  227. with self.metadata_edit({"jwks_uri": None}):
  228. # If we don't do this, the load_metadata call will throw because of the
  229. # missing jwks_uri
  230. self.provider._user_profile_method = "userinfo_endpoint"
  231. self.get_success(self.provider.load_metadata(force=True))
  232. self.get_failure(self.provider.load_jwks(force=True), RuntimeError)
  233. @override_config({"oidc_config": DEFAULT_CONFIG})
  234. def test_validate_config(self) -> None:
  235. """Provider metadatas are extensively validated."""
  236. h = self.provider
  237. def force_load_metadata() -> Awaitable[None]:
  238. async def force_load() -> "OpenIDProviderMetadata":
  239. return await h.load_metadata(force=True)
  240. return get_awaitable_result(force_load())
  241. # Default test config does not throw
  242. force_load_metadata()
  243. with self.metadata_edit({"issuer": None}):
  244. self.assertRaisesRegex(ValueError, "issuer", force_load_metadata)
  245. with self.metadata_edit({"issuer": "http://insecure/"}):
  246. self.assertRaisesRegex(ValueError, "issuer", force_load_metadata)
  247. with self.metadata_edit({"issuer": "https://invalid/?because=query"}):
  248. self.assertRaisesRegex(ValueError, "issuer", force_load_metadata)
  249. with self.metadata_edit({"authorization_endpoint": None}):
  250. self.assertRaisesRegex(
  251. ValueError, "authorization_endpoint", force_load_metadata
  252. )
  253. with self.metadata_edit({"authorization_endpoint": "http://insecure/auth"}):
  254. self.assertRaisesRegex(
  255. ValueError, "authorization_endpoint", force_load_metadata
  256. )
  257. with self.metadata_edit({"token_endpoint": None}):
  258. self.assertRaisesRegex(ValueError, "token_endpoint", force_load_metadata)
  259. with self.metadata_edit({"token_endpoint": "http://insecure/token"}):
  260. self.assertRaisesRegex(ValueError, "token_endpoint", force_load_metadata)
  261. with self.metadata_edit({"jwks_uri": None}):
  262. self.assertRaisesRegex(ValueError, "jwks_uri", force_load_metadata)
  263. with self.metadata_edit({"jwks_uri": "http://insecure/jwks.json"}):
  264. self.assertRaisesRegex(ValueError, "jwks_uri", force_load_metadata)
  265. with self.metadata_edit({"response_types_supported": ["id_token"]}):
  266. self.assertRaisesRegex(
  267. ValueError, "response_types_supported", force_load_metadata
  268. )
  269. with self.metadata_edit(
  270. {"token_endpoint_auth_methods_supported": ["client_secret_basic"]}
  271. ):
  272. # should not throw, as client_secret_basic is the default auth method
  273. force_load_metadata()
  274. with self.metadata_edit(
  275. {"token_endpoint_auth_methods_supported": ["client_secret_post"]}
  276. ):
  277. self.assertRaisesRegex(
  278. ValueError,
  279. "token_endpoint_auth_methods_supported",
  280. force_load_metadata,
  281. )
  282. # Tests for configs that require the userinfo endpoint
  283. self.assertFalse(h._uses_userinfo)
  284. self.assertEqual(h._user_profile_method, "auto")
  285. h._user_profile_method = "userinfo_endpoint"
  286. self.assertTrue(h._uses_userinfo)
  287. # Revert the profile method and do not request the "openid" scope: this should
  288. # mean that we check for a userinfo endpoint
  289. h._user_profile_method = "auto"
  290. h._scopes = []
  291. self.assertTrue(h._uses_userinfo)
  292. with self.metadata_edit({"userinfo_endpoint": None}):
  293. self.assertRaisesRegex(ValueError, "userinfo_endpoint", force_load_metadata)
  294. with self.metadata_edit({"jwks_uri": None}):
  295. # Shouldn't raise with a valid userinfo, even without jwks
  296. force_load_metadata()
  297. @override_config({"oidc_config": {**DEFAULT_CONFIG, "skip_verification": True}})
  298. def test_skip_verification(self) -> None:
  299. """Provider metadata validation can be disabled by config."""
  300. with self.metadata_edit({"issuer": "http://insecure"}):
  301. # This should not throw
  302. get_awaitable_result(self.provider.load_metadata())
  303. @override_config({"oidc_config": DEFAULT_CONFIG})
  304. def test_redirect_request(self) -> None:
  305. """The redirect request has the right arguments & generates a valid session cookie."""
  306. req = Mock(spec=["cookies"])
  307. req.cookies = []
  308. url = urlparse(
  309. self.get_success(
  310. self.provider.handle_redirect_request(req, b"http://client/redirect")
  311. )
  312. )
  313. auth_endpoint = urlparse(self.fake_server.authorization_endpoint)
  314. self.assertEqual(url.scheme, auth_endpoint.scheme)
  315. self.assertEqual(url.netloc, auth_endpoint.netloc)
  316. self.assertEqual(url.path, auth_endpoint.path)
  317. params = parse_qs(url.query)
  318. self.assertEqual(params["redirect_uri"], [CALLBACK_URL])
  319. self.assertEqual(params["response_type"], ["code"])
  320. self.assertEqual(params["scope"], [" ".join(SCOPES)])
  321. self.assertEqual(params["client_id"], [CLIENT_ID])
  322. self.assertEqual(len(params["state"]), 1)
  323. self.assertEqual(len(params["nonce"]), 1)
  324. # Check what is in the cookies
  325. self.assertEqual(len(req.cookies), 2) # two cookies
  326. cookie_header = req.cookies[0]
  327. # The cookie name and path don't really matter, just that it has to be coherent
  328. # between the callback & redirect handlers.
  329. parts = [p.strip() for p in cookie_header.split(b";")]
  330. self.assertIn(b"Path=/_synapse/client/oidc", parts)
  331. name, cookie = parts[0].split(b"=")
  332. self.assertEqual(name, b"oidc_session")
  333. macaroon = pymacaroons.Macaroon.deserialize(cookie)
  334. state = get_value_from_macaroon(macaroon, "state")
  335. nonce = get_value_from_macaroon(macaroon, "nonce")
  336. redirect = get_value_from_macaroon(macaroon, "client_redirect_url")
  337. self.assertEqual(params["state"], [state])
  338. self.assertEqual(params["nonce"], [nonce])
  339. self.assertEqual(redirect, "http://client/redirect")
  340. @override_config({"oidc_config": DEFAULT_CONFIG})
  341. def test_callback_error(self) -> None:
  342. """Errors from the provider returned in the callback are displayed."""
  343. request = Mock(args={})
  344. request.args[b"error"] = [b"invalid_client"]
  345. self.get_success(self.handler.handle_oidc_callback(request))
  346. self.assertRenderedError("invalid_client", "")
  347. request.args[b"error_description"] = [b"some description"]
  348. self.get_success(self.handler.handle_oidc_callback(request))
  349. self.assertRenderedError("invalid_client", "some description")
  350. @override_config({"oidc_config": DEFAULT_CONFIG})
  351. def test_callback(self) -> None:
  352. """Code callback works and display errors if something went wrong.
  353. A lot of scenarios are tested here:
  354. - when the callback works, with userinfo from ID token
  355. - when the user mapping fails
  356. - when ID token verification fails
  357. - when the callback works, with userinfo fetched from the userinfo endpoint
  358. - when the userinfo fetching fails
  359. - when the code exchange fails
  360. """
  361. # ensure that we are correctly testing the fallback when "get_extra_attributes"
  362. # is not implemented.
  363. mapping_provider = self.provider._user_mapping_provider
  364. with self.assertRaises(AttributeError):
  365. _ = mapping_provider.get_extra_attributes
  366. username = "bar"
  367. userinfo = {
  368. "sub": "foo",
  369. "username": username,
  370. }
  371. expected_user_id = "@%s:%s" % (username, self.hs.hostname)
  372. client_redirect_url = "http://client/redirect"
  373. request, _ = self.start_authorization(
  374. userinfo, client_redirect_url=client_redirect_url
  375. )
  376. self.get_success(self.handler.handle_oidc_callback(request))
  377. self.complete_sso_login.assert_called_once_with(
  378. expected_user_id,
  379. self.provider.idp_id,
  380. request,
  381. client_redirect_url,
  382. None,
  383. new_user=True,
  384. auth_provider_session_id=None,
  385. )
  386. self.fake_server.post_token_handler.assert_called_once()
  387. self.fake_server.get_userinfo_handler.assert_not_called()
  388. self.render_error.assert_not_called()
  389. # Handle mapping errors
  390. request, _ = self.start_authorization(userinfo)
  391. with patch.object(
  392. self.provider,
  393. "_remote_id_from_userinfo",
  394. new=Mock(side_effect=MappingException()),
  395. ):
  396. self.get_success(self.handler.handle_oidc_callback(request))
  397. self.assertRenderedError("mapping_error")
  398. # Handle ID token errors
  399. request, _ = self.start_authorization(userinfo)
  400. with self.fake_server.id_token_override({"iss": "https://bad.issuer/"}):
  401. self.get_success(self.handler.handle_oidc_callback(request))
  402. self.assertRenderedError("invalid_token")
  403. self.reset_mocks()
  404. # With userinfo fetching
  405. self.provider._user_profile_method = "userinfo_endpoint"
  406. # Without the "openid" scope, the FakeProvider does not generate an id_token
  407. request, _ = self.start_authorization(userinfo, scope="")
  408. self.get_success(self.handler.handle_oidc_callback(request))
  409. self.complete_sso_login.assert_called_once_with(
  410. expected_user_id,
  411. self.provider.idp_id,
  412. request,
  413. ANY,
  414. None,
  415. new_user=False,
  416. auth_provider_session_id=None,
  417. )
  418. self.fake_server.post_token_handler.assert_called_once()
  419. self.fake_server.get_userinfo_handler.assert_called_once()
  420. self.render_error.assert_not_called()
  421. self.reset_mocks()
  422. # With an ID token, userinfo fetching and sid in the ID token
  423. self.provider._user_profile_method = "userinfo_endpoint"
  424. request, grant = self.start_authorization(userinfo, with_sid=True)
  425. self.assertIsNotNone(grant.sid)
  426. self.get_success(self.handler.handle_oidc_callback(request))
  427. self.complete_sso_login.assert_called_once_with(
  428. expected_user_id,
  429. self.provider.idp_id,
  430. request,
  431. ANY,
  432. None,
  433. new_user=False,
  434. auth_provider_session_id=grant.sid,
  435. )
  436. self.fake_server.post_token_handler.assert_called_once()
  437. self.fake_server.get_userinfo_handler.assert_called_once()
  438. self.render_error.assert_not_called()
  439. # Handle userinfo fetching error
  440. request, _ = self.start_authorization(userinfo)
  441. with self.fake_server.buggy_endpoint(userinfo=True):
  442. self.get_success(self.handler.handle_oidc_callback(request))
  443. self.assertRenderedError("fetch_error")
  444. request, _ = self.start_authorization(userinfo)
  445. with self.fake_server.buggy_endpoint(token=True):
  446. self.get_success(self.handler.handle_oidc_callback(request))
  447. self.assertRenderedError("server_error")
  448. @override_config({"oidc_config": DEFAULT_CONFIG})
  449. def test_callback_session(self) -> None:
  450. """The callback verifies the session presence and validity"""
  451. request = Mock(spec=["args", "getCookie", "cookies"])
  452. # Missing cookie
  453. request.args = {}
  454. request.getCookie.return_value = None
  455. self.get_success(self.handler.handle_oidc_callback(request))
  456. self.assertRenderedError("missing_session", "No session cookie found")
  457. # Missing session parameter
  458. request.args = {}
  459. request.getCookie.return_value = "session"
  460. self.get_success(self.handler.handle_oidc_callback(request))
  461. self.assertRenderedError("invalid_request", "State parameter is missing")
  462. # Invalid cookie
  463. request.args = {}
  464. request.args[b"state"] = [b"state"]
  465. request.getCookie.return_value = "session"
  466. self.get_success(self.handler.handle_oidc_callback(request))
  467. self.assertRenderedError("invalid_session")
  468. # Mismatching session
  469. session = self._generate_oidc_session_token(
  470. state="state",
  471. nonce="nonce",
  472. client_redirect_url="http://client/redirect",
  473. )
  474. request.args = {}
  475. request.args[b"state"] = [b"mismatching state"]
  476. request.getCookie.return_value = session
  477. self.get_success(self.handler.handle_oidc_callback(request))
  478. self.assertRenderedError("mismatching_session")
  479. # Valid session
  480. request.args = {}
  481. request.args[b"state"] = [b"state"]
  482. request.getCookie.return_value = session
  483. self.get_success(self.handler.handle_oidc_callback(request))
  484. self.assertRenderedError("invalid_request")
  485. @override_config(
  486. {"oidc_config": {**DEFAULT_CONFIG, "client_auth_method": "client_secret_post"}}
  487. )
  488. def test_exchange_code(self) -> None:
  489. """Code exchange behaves correctly and handles various error scenarios."""
  490. token = {
  491. "type": "Bearer",
  492. "access_token": "aabbcc",
  493. }
  494. self.fake_server.post_token_handler.side_effect = None
  495. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  496. payload=token
  497. )
  498. code = "code"
  499. ret = self.get_success(self.provider._exchange_code(code))
  500. kwargs = self.fake_server.request.call_args[1]
  501. self.assertEqual(ret, token)
  502. self.assertEqual(kwargs["method"], "POST")
  503. self.assertEqual(kwargs["uri"], self.fake_server.token_endpoint)
  504. args = parse_qs(kwargs["data"].decode("utf-8"))
  505. self.assertEqual(args["grant_type"], ["authorization_code"])
  506. self.assertEqual(args["code"], [code])
  507. self.assertEqual(args["client_id"], [CLIENT_ID])
  508. self.assertEqual(args["client_secret"], [CLIENT_SECRET])
  509. self.assertEqual(args["redirect_uri"], [CALLBACK_URL])
  510. # Test error handling
  511. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  512. code=400, payload={"error": "foo", "error_description": "bar"}
  513. )
  514. from synapse.handlers.oidc import OidcError
  515. exc = self.get_failure(self.provider._exchange_code(code), OidcError)
  516. self.assertEqual(exc.value.error, "foo")
  517. self.assertEqual(exc.value.error_description, "bar")
  518. # Internal server error with no JSON body
  519. self.fake_server.post_token_handler.return_value = FakeResponse(
  520. code=500, body=b"Not JSON"
  521. )
  522. exc = self.get_failure(self.provider._exchange_code(code), OidcError)
  523. self.assertEqual(exc.value.error, "server_error")
  524. # Internal server error with JSON body
  525. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  526. code=500, payload={"error": "internal_server_error"}
  527. )
  528. exc = self.get_failure(self.provider._exchange_code(code), OidcError)
  529. self.assertEqual(exc.value.error, "internal_server_error")
  530. # 4xx error without "error" field
  531. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  532. code=400, payload={}
  533. )
  534. exc = self.get_failure(self.provider._exchange_code(code), OidcError)
  535. self.assertEqual(exc.value.error, "server_error")
  536. # 2xx error with "error" field
  537. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  538. code=200, payload={"error": "some_error"}
  539. )
  540. exc = self.get_failure(self.provider._exchange_code(code), OidcError)
  541. self.assertEqual(exc.value.error, "some_error")
  542. @override_config(
  543. {
  544. "oidc_config": {
  545. "enabled": True,
  546. "client_id": CLIENT_ID,
  547. "issuer": ISSUER,
  548. "client_auth_method": "client_secret_post",
  549. "client_secret_jwt_key": {
  550. "key_file": _key_file_path(),
  551. "jwt_header": {"alg": "ES256", "kid": "ABC789"},
  552. "jwt_payload": {"iss": "DEFGHI"},
  553. },
  554. }
  555. }
  556. )
  557. def test_exchange_code_jwt_key(self) -> None:
  558. """Test that code exchange works with a JWK client secret."""
  559. from authlib.jose import jwt
  560. token = {
  561. "type": "Bearer",
  562. "access_token": "aabbcc",
  563. }
  564. self.fake_server.post_token_handler.side_effect = None
  565. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  566. payload=token
  567. )
  568. code = "code"
  569. # advance the clock a bit before we start, so we aren't working with zero
  570. # timestamps.
  571. self.reactor.advance(1000)
  572. start_time = self.reactor.seconds()
  573. ret = self.get_success(self.provider._exchange_code(code))
  574. self.assertEqual(ret, token)
  575. # the request should have hit the token endpoint
  576. kwargs = self.fake_server.request.call_args[1]
  577. self.assertEqual(kwargs["method"], "POST")
  578. self.assertEqual(kwargs["uri"], self.fake_server.token_endpoint)
  579. # the client secret provided to the should be a jwt which can be checked with
  580. # the public key
  581. args = parse_qs(kwargs["data"].decode("utf-8"))
  582. secret = args["client_secret"][0]
  583. with open(_public_key_file_path()) as f:
  584. key = f.read()
  585. claims = jwt.decode(secret, key)
  586. self.assertEqual(claims.header["kid"], "ABC789")
  587. self.assertEqual(claims["aud"], ISSUER)
  588. self.assertEqual(claims["iss"], "DEFGHI")
  589. self.assertEqual(claims["sub"], CLIENT_ID)
  590. self.assertEqual(claims["iat"], start_time)
  591. self.assertGreater(claims["exp"], start_time)
  592. # check the rest of the POSTed data
  593. self.assertEqual(args["grant_type"], ["authorization_code"])
  594. self.assertEqual(args["code"], [code])
  595. self.assertEqual(args["client_id"], [CLIENT_ID])
  596. self.assertEqual(args["redirect_uri"], [CALLBACK_URL])
  597. @override_config(
  598. {
  599. "oidc_config": {
  600. "enabled": True,
  601. "client_id": CLIENT_ID,
  602. "issuer": ISSUER,
  603. "client_auth_method": "none",
  604. }
  605. }
  606. )
  607. def test_exchange_code_no_auth(self) -> None:
  608. """Test that code exchange works with no client secret."""
  609. token = {
  610. "type": "Bearer",
  611. "access_token": "aabbcc",
  612. }
  613. self.fake_server.post_token_handler.side_effect = None
  614. self.fake_server.post_token_handler.return_value = FakeResponse.json(
  615. payload=token
  616. )
  617. code = "code"
  618. ret = self.get_success(self.provider._exchange_code(code))
  619. self.assertEqual(ret, token)
  620. # the request should have hit the token endpoint
  621. kwargs = self.fake_server.request.call_args[1]
  622. self.assertEqual(kwargs["method"], "POST")
  623. self.assertEqual(kwargs["uri"], self.fake_server.token_endpoint)
  624. # check the POSTed data
  625. args = parse_qs(kwargs["data"].decode("utf-8"))
  626. self.assertEqual(args["grant_type"], ["authorization_code"])
  627. self.assertEqual(args["code"], [code])
  628. self.assertEqual(args["client_id"], [CLIENT_ID])
  629. self.assertEqual(args["redirect_uri"], [CALLBACK_URL])
  630. @override_config(
  631. {
  632. "oidc_config": {
  633. **DEFAULT_CONFIG,
  634. "user_mapping_provider": {
  635. "module": __name__ + ".TestMappingProviderExtra"
  636. },
  637. }
  638. }
  639. )
  640. def test_extra_attributes(self) -> None:
  641. """
  642. Login while using a mapping provider that implements get_extra_attributes.
  643. """
  644. userinfo = {
  645. "sub": "foo",
  646. "username": "foo",
  647. "phone": "1234567",
  648. }
  649. request, _ = self.start_authorization(userinfo)
  650. self.get_success(self.handler.handle_oidc_callback(request))
  651. self.complete_sso_login.assert_called_once_with(
  652. "@foo:test",
  653. self.provider.idp_id,
  654. request,
  655. ANY,
  656. {"phone": "1234567"},
  657. new_user=True,
  658. auth_provider_session_id=None,
  659. )
  660. @override_config({"oidc_config": DEFAULT_CONFIG})
  661. def test_map_userinfo_to_user(self) -> None:
  662. """Ensure that mapping the userinfo returned from a provider to an MXID works properly."""
  663. userinfo: dict = {
  664. "sub": "test_user",
  665. "username": "test_user",
  666. }
  667. request, _ = self.start_authorization(userinfo)
  668. self.get_success(self.handler.handle_oidc_callback(request))
  669. self.complete_sso_login.assert_called_once_with(
  670. "@test_user:test",
  671. self.provider.idp_id,
  672. request,
  673. ANY,
  674. None,
  675. new_user=True,
  676. auth_provider_session_id=None,
  677. )
  678. self.reset_mocks()
  679. # Some providers return an integer ID.
  680. userinfo = {
  681. "sub": 1234,
  682. "username": "test_user_2",
  683. }
  684. request, _ = self.start_authorization(userinfo)
  685. self.get_success(self.handler.handle_oidc_callback(request))
  686. self.complete_sso_login.assert_called_once_with(
  687. "@test_user_2:test",
  688. self.provider.idp_id,
  689. request,
  690. ANY,
  691. None,
  692. new_user=True,
  693. auth_provider_session_id=None,
  694. )
  695. self.reset_mocks()
  696. # Test if the mxid is already taken
  697. store = self.hs.get_datastores().main
  698. user3 = UserID.from_string("@test_user_3:test")
  699. self.get_success(
  700. store.register_user(user_id=user3.to_string(), password_hash=None)
  701. )
  702. userinfo = {"sub": "test3", "username": "test_user_3"}
  703. request, _ = self.start_authorization(userinfo)
  704. self.get_success(self.handler.handle_oidc_callback(request))
  705. self.complete_sso_login.assert_not_called()
  706. self.assertRenderedError(
  707. "mapping_error",
  708. "Mapping provider does not support de-duplicating Matrix IDs",
  709. )
  710. @override_config({"oidc_config": {**DEFAULT_CONFIG, "allow_existing_users": True}})
  711. def test_map_userinfo_to_existing_user(self) -> None:
  712. """Existing users can log in with OpenID Connect when allow_existing_users is True."""
  713. store = self.hs.get_datastores().main
  714. user = UserID.from_string("@test_user:test")
  715. self.get_success(
  716. store.register_user(user_id=user.to_string(), password_hash=None)
  717. )
  718. # Map a user via SSO.
  719. userinfo = {
  720. "sub": "test",
  721. "username": "test_user",
  722. }
  723. request, _ = self.start_authorization(userinfo)
  724. self.get_success(self.handler.handle_oidc_callback(request))
  725. self.complete_sso_login.assert_called_once_with(
  726. user.to_string(),
  727. self.provider.idp_id,
  728. request,
  729. ANY,
  730. None,
  731. new_user=False,
  732. auth_provider_session_id=None,
  733. )
  734. self.reset_mocks()
  735. # Subsequent calls should map to the same mxid.
  736. request, _ = self.start_authorization(userinfo)
  737. self.get_success(self.handler.handle_oidc_callback(request))
  738. self.complete_sso_login.assert_called_once_with(
  739. user.to_string(),
  740. self.provider.idp_id,
  741. request,
  742. ANY,
  743. None,
  744. new_user=False,
  745. auth_provider_session_id=None,
  746. )
  747. self.reset_mocks()
  748. # Note that a second SSO user can be mapped to the same Matrix ID. (This
  749. # requires a unique sub, but something that maps to the same matrix ID,
  750. # in this case we'll just use the same username. A more realistic example
  751. # would be subs which are email addresses, and mapping from the localpart
  752. # of the email, e.g. bob@foo.com and bob@bar.com -> @bob:test.)
  753. userinfo = {
  754. "sub": "test1",
  755. "username": "test_user",
  756. }
  757. request, _ = self.start_authorization(userinfo)
  758. self.get_success(self.handler.handle_oidc_callback(request))
  759. self.complete_sso_login.assert_called_once_with(
  760. user.to_string(),
  761. self.provider.idp_id,
  762. request,
  763. ANY,
  764. None,
  765. new_user=False,
  766. auth_provider_session_id=None,
  767. )
  768. self.reset_mocks()
  769. # Register some non-exact matching cases.
  770. user2 = UserID.from_string("@TEST_user_2:test")
  771. self.get_success(
  772. store.register_user(user_id=user2.to_string(), password_hash=None)
  773. )
  774. user2_caps = UserID.from_string("@test_USER_2:test")
  775. self.get_success(
  776. store.register_user(user_id=user2_caps.to_string(), password_hash=None)
  777. )
  778. # Attempting to login without matching a name exactly is an error.
  779. userinfo = {
  780. "sub": "test2",
  781. "username": "TEST_USER_2",
  782. }
  783. request, _ = self.start_authorization(userinfo)
  784. self.get_success(self.handler.handle_oidc_callback(request))
  785. self.complete_sso_login.assert_not_called()
  786. args = self.assertRenderedError("mapping_error")
  787. self.assertTrue(
  788. args[2].startswith(
  789. "Attempted to login as '@TEST_USER_2:test' but it matches more than one user inexactly:"
  790. )
  791. )
  792. # Logging in when matching a name exactly should work.
  793. user2 = UserID.from_string("@TEST_USER_2:test")
  794. self.get_success(
  795. store.register_user(user_id=user2.to_string(), password_hash=None)
  796. )
  797. request, _ = self.start_authorization(userinfo)
  798. self.get_success(self.handler.handle_oidc_callback(request))
  799. self.complete_sso_login.assert_called_once_with(
  800. "@TEST_USER_2:test",
  801. self.provider.idp_id,
  802. request,
  803. ANY,
  804. None,
  805. new_user=False,
  806. auth_provider_session_id=None,
  807. )
  808. @override_config({"oidc_config": DEFAULT_CONFIG})
  809. def test_map_userinfo_to_invalid_localpart(self) -> None:
  810. """If the mapping provider generates an invalid localpart it should be rejected."""
  811. userinfo = {"sub": "test2", "username": "föö"}
  812. request, _ = self.start_authorization(userinfo)
  813. self.get_success(self.handler.handle_oidc_callback(request))
  814. self.assertRenderedError("mapping_error", "localpart is invalid: föö")
  815. @override_config(
  816. {
  817. "oidc_config": {
  818. **DEFAULT_CONFIG,
  819. "user_mapping_provider": {
  820. "module": __name__ + ".TestMappingProviderFailures"
  821. },
  822. }
  823. }
  824. )
  825. def test_map_userinfo_to_user_retries(self) -> None:
  826. """The mapping provider can retry generating an MXID if the MXID is already in use."""
  827. store = self.hs.get_datastores().main
  828. self.get_success(
  829. store.register_user(user_id="@test_user:test", password_hash=None)
  830. )
  831. userinfo = {
  832. "sub": "test",
  833. "username": "test_user",
  834. }
  835. request, _ = self.start_authorization(userinfo)
  836. self.get_success(self.handler.handle_oidc_callback(request))
  837. # test_user is already taken, so test_user1 gets registered instead.
  838. self.complete_sso_login.assert_called_once_with(
  839. "@test_user1:test",
  840. self.provider.idp_id,
  841. request,
  842. ANY,
  843. None,
  844. new_user=True,
  845. auth_provider_session_id=None,
  846. )
  847. self.reset_mocks()
  848. # Register all of the potential mxids for a particular OIDC username.
  849. self.get_success(
  850. store.register_user(user_id="@tester:test", password_hash=None)
  851. )
  852. for i in range(1, 3):
  853. self.get_success(
  854. store.register_user(user_id="@tester%d:test" % i, password_hash=None)
  855. )
  856. # Now attempt to map to a username, this will fail since all potential usernames are taken.
  857. userinfo = {
  858. "sub": "tester",
  859. "username": "tester",
  860. }
  861. request, _ = self.start_authorization(userinfo)
  862. self.get_success(self.handler.handle_oidc_callback(request))
  863. self.complete_sso_login.assert_not_called()
  864. self.assertRenderedError(
  865. "mapping_error", "Unable to generate a Matrix ID from the SSO response"
  866. )
  867. @override_config({"oidc_config": DEFAULT_CONFIG})
  868. def test_empty_localpart(self) -> None:
  869. """Attempts to map onto an empty localpart should be rejected."""
  870. userinfo = {
  871. "sub": "tester",
  872. "username": "",
  873. }
  874. request, _ = self.start_authorization(userinfo)
  875. self.get_success(self.handler.handle_oidc_callback(request))
  876. self.assertRenderedError("mapping_error", "localpart is invalid: ")
  877. @override_config(
  878. {
  879. "oidc_config": {
  880. **DEFAULT_CONFIG,
  881. "user_mapping_provider": {
  882. "config": {"localpart_template": "{{ user.username }}"}
  883. },
  884. }
  885. }
  886. )
  887. def test_null_localpart(self) -> None:
  888. """Mapping onto a null localpart via an empty OIDC attribute should be rejected"""
  889. userinfo = {
  890. "sub": "tester",
  891. "username": None,
  892. }
  893. request, _ = self.start_authorization(userinfo)
  894. self.get_success(self.handler.handle_oidc_callback(request))
  895. self.assertRenderedError("mapping_error", "localpart is invalid: ")
  896. @override_config(
  897. {
  898. "oidc_config": {
  899. **DEFAULT_CONFIG,
  900. "attribute_requirements": [{"attribute": "test", "value": "foobar"}],
  901. }
  902. }
  903. )
  904. def test_attribute_requirements(self) -> None:
  905. """The required attributes must be met from the OIDC userinfo response."""
  906. # userinfo lacking "test": "foobar" attribute should fail.
  907. userinfo = {
  908. "sub": "tester",
  909. "username": "tester",
  910. }
  911. request, _ = self.start_authorization(userinfo)
  912. self.get_success(self.handler.handle_oidc_callback(request))
  913. self.complete_sso_login.assert_not_called()
  914. # userinfo with "test": "foobar" attribute should succeed.
  915. userinfo = {
  916. "sub": "tester",
  917. "username": "tester",
  918. "test": "foobar",
  919. }
  920. request, _ = self.start_authorization(userinfo)
  921. self.get_success(self.handler.handle_oidc_callback(request))
  922. # check that the auth handler got called as expected
  923. self.complete_sso_login.assert_called_once_with(
  924. "@tester:test",
  925. self.provider.idp_id,
  926. request,
  927. ANY,
  928. None,
  929. new_user=True,
  930. auth_provider_session_id=None,
  931. )
  932. @override_config(
  933. {
  934. "oidc_config": {
  935. **DEFAULT_CONFIG,
  936. "attribute_requirements": [{"attribute": "test", "value": "foobar"}],
  937. }
  938. }
  939. )
  940. def test_attribute_requirements_contains(self) -> None:
  941. """Test that auth succeeds if userinfo attribute CONTAINS required value"""
  942. # userinfo with "test": ["foobar", "foo", "bar"] attribute should succeed.
  943. userinfo = {
  944. "sub": "tester",
  945. "username": "tester",
  946. "test": ["foobar", "foo", "bar"],
  947. }
  948. request, _ = self.start_authorization(userinfo)
  949. self.get_success(self.handler.handle_oidc_callback(request))
  950. # check that the auth handler got called as expected
  951. self.complete_sso_login.assert_called_once_with(
  952. "@tester:test",
  953. self.provider.idp_id,
  954. request,
  955. ANY,
  956. None,
  957. new_user=True,
  958. auth_provider_session_id=None,
  959. )
  960. @override_config(
  961. {
  962. "oidc_config": {
  963. **DEFAULT_CONFIG,
  964. "attribute_requirements": [{"attribute": "test", "value": "foobar"}],
  965. }
  966. }
  967. )
  968. def test_attribute_requirements_mismatch(self) -> None:
  969. """
  970. Test that auth fails if attributes exist but don't match,
  971. or are non-string values.
  972. """
  973. # userinfo with "test": "not_foobar" attribute should fail
  974. userinfo: dict = {
  975. "sub": "tester",
  976. "username": "tester",
  977. "test": "not_foobar",
  978. }
  979. request, _ = self.start_authorization(userinfo)
  980. self.get_success(self.handler.handle_oidc_callback(request))
  981. self.complete_sso_login.assert_not_called()
  982. # userinfo with "test": ["foo", "bar"] attribute should fail
  983. userinfo = {
  984. "sub": "tester",
  985. "username": "tester",
  986. "test": ["foo", "bar"],
  987. }
  988. request, _ = self.start_authorization(userinfo)
  989. self.get_success(self.handler.handle_oidc_callback(request))
  990. self.complete_sso_login.assert_not_called()
  991. # userinfo with "test": False attribute should fail
  992. # this is largely just to ensure we don't crash here
  993. userinfo = {
  994. "sub": "tester",
  995. "username": "tester",
  996. "test": False,
  997. }
  998. request, _ = self.start_authorization(userinfo)
  999. self.get_success(self.handler.handle_oidc_callback(request))
  1000. self.complete_sso_login.assert_not_called()
  1001. # userinfo with "test": None attribute should fail
  1002. # a value of None breaks the OIDC spec, but it's important to not crash here
  1003. userinfo = {
  1004. "sub": "tester",
  1005. "username": "tester",
  1006. "test": None,
  1007. }
  1008. request, _ = self.start_authorization(userinfo)
  1009. self.get_success(self.handler.handle_oidc_callback(request))
  1010. self.complete_sso_login.assert_not_called()
  1011. # userinfo with "test": 1 attribute should fail
  1012. # this is largely just to ensure we don't crash here
  1013. userinfo = {
  1014. "sub": "tester",
  1015. "username": "tester",
  1016. "test": 1,
  1017. }
  1018. request, _ = self.start_authorization(userinfo)
  1019. self.get_success(self.handler.handle_oidc_callback(request))
  1020. self.complete_sso_login.assert_not_called()
  1021. # userinfo with "test": 3.14 attribute should fail
  1022. # this is largely just to ensure we don't crash here
  1023. userinfo = {
  1024. "sub": "tester",
  1025. "username": "tester",
  1026. "test": 3.14,
  1027. }
  1028. request, _ = self.start_authorization(userinfo)
  1029. self.get_success(self.handler.handle_oidc_callback(request))
  1030. self.complete_sso_login.assert_not_called()
  1031. def _generate_oidc_session_token(
  1032. self,
  1033. state: str,
  1034. nonce: str,
  1035. client_redirect_url: str,
  1036. ui_auth_session_id: str = "",
  1037. ) -> str:
  1038. from synapse.handlers.oidc import OidcSessionData
  1039. return self.handler._macaroon_generator.generate_oidc_session_token(
  1040. state=state,
  1041. session_data=OidcSessionData(
  1042. idp_id=self.provider.idp_id,
  1043. nonce=nonce,
  1044. client_redirect_url=client_redirect_url,
  1045. ui_auth_session_id=ui_auth_session_id,
  1046. ),
  1047. )
  1048. def _build_callback_request(
  1049. code: str,
  1050. state: str,
  1051. session: str,
  1052. ip_address: str = "10.0.0.1",
  1053. ) -> Mock:
  1054. """Builds a fake SynapseRequest to mock the browser callback
  1055. Returns a Mock object which looks like the SynapseRequest we get from a browser
  1056. after SSO (before we return to the client)
  1057. Args:
  1058. code: the authorization code which would have been returned by the OIDC
  1059. provider
  1060. state: the "state" param which would have been passed around in the
  1061. query param. Should be the same as was embedded in the session in
  1062. _build_oidc_session.
  1063. session: the "session" which would have been passed around in the cookie.
  1064. ip_address: the IP address to pretend the request came from
  1065. """
  1066. request = Mock(
  1067. spec=[
  1068. "args",
  1069. "getCookie",
  1070. "cookies",
  1071. "requestHeaders",
  1072. "getClientAddress",
  1073. "getHeader",
  1074. ]
  1075. )
  1076. request.cookies = []
  1077. request.getCookie.return_value = session
  1078. request.args = {}
  1079. request.args[b"code"] = [code.encode("utf-8")]
  1080. request.args[b"state"] = [state.encode("utf-8")]
  1081. request.getClientAddress.return_value.host = ip_address
  1082. return request