test_oidc.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. # -*- coding: utf-8 -*-
  2. # Copyright 2020 Quentin Gliech
  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 json
  16. from urllib.parse import parse_qs, urlparse
  17. from mock import Mock, patch
  18. import attr
  19. import pymacaroons
  20. from twisted.python.failure import Failure
  21. from twisted.web._newclient import ResponseDone
  22. from synapse.handlers.oidc_handler import OidcError, OidcMappingProvider
  23. from synapse.handlers.sso import MappingException
  24. from synapse.types import UserID
  25. from tests.unittest import HomeserverTestCase, override_config
  26. @attr.s
  27. class FakeResponse:
  28. code = attr.ib()
  29. body = attr.ib()
  30. phrase = attr.ib()
  31. def deliverBody(self, protocol):
  32. protocol.dataReceived(self.body)
  33. protocol.connectionLost(Failure(ResponseDone()))
  34. # These are a few constants that are used as config parameters in the tests.
  35. ISSUER = "https://issuer/"
  36. CLIENT_ID = "test-client-id"
  37. CLIENT_SECRET = "test-client-secret"
  38. BASE_URL = "https://synapse/"
  39. CALLBACK_URL = BASE_URL + "_synapse/oidc/callback"
  40. SCOPES = ["openid"]
  41. AUTHORIZATION_ENDPOINT = ISSUER + "authorize"
  42. TOKEN_ENDPOINT = ISSUER + "token"
  43. USERINFO_ENDPOINT = ISSUER + "userinfo"
  44. WELL_KNOWN = ISSUER + ".well-known/openid-configuration"
  45. JWKS_URI = ISSUER + ".well-known/jwks.json"
  46. # config for common cases
  47. COMMON_CONFIG = {
  48. "discover": False,
  49. "authorization_endpoint": AUTHORIZATION_ENDPOINT,
  50. "token_endpoint": TOKEN_ENDPOINT,
  51. "jwks_uri": JWKS_URI,
  52. }
  53. # The cookie name and path don't really matter, just that it has to be coherent
  54. # between the callback & redirect handlers.
  55. COOKIE_NAME = b"oidc_session"
  56. COOKIE_PATH = "/_synapse/oidc"
  57. class TestMappingProvider(OidcMappingProvider):
  58. @staticmethod
  59. def parse_config(config):
  60. return
  61. def get_remote_user_id(self, userinfo):
  62. return userinfo["sub"]
  63. async def map_user_attributes(self, userinfo, token):
  64. return {"localpart": userinfo["username"], "display_name": None}
  65. # Do not include get_extra_attributes to test backwards compatibility paths.
  66. class TestMappingProviderExtra(TestMappingProvider):
  67. async def get_extra_attributes(self, userinfo, token):
  68. return {"phone": userinfo["phone"]}
  69. class TestMappingProviderFailures(TestMappingProvider):
  70. async def map_user_attributes(self, userinfo, token, failures):
  71. return {
  72. "localpart": userinfo["username"] + (str(failures) if failures else ""),
  73. "display_name": None,
  74. }
  75. def simple_async_mock(return_value=None, raises=None):
  76. # AsyncMock is not available in python3.5, this mimics part of its behaviour
  77. async def cb(*args, **kwargs):
  78. if raises:
  79. raise raises
  80. return return_value
  81. return Mock(side_effect=cb)
  82. async def get_json(url):
  83. # Mock get_json calls to handle jwks & oidc discovery endpoints
  84. if url == WELL_KNOWN:
  85. # Minimal discovery document, as defined in OpenID.Discovery
  86. # https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata
  87. return {
  88. "issuer": ISSUER,
  89. "authorization_endpoint": AUTHORIZATION_ENDPOINT,
  90. "token_endpoint": TOKEN_ENDPOINT,
  91. "jwks_uri": JWKS_URI,
  92. "userinfo_endpoint": USERINFO_ENDPOINT,
  93. "response_types_supported": ["code"],
  94. "subject_types_supported": ["public"],
  95. "id_token_signing_alg_values_supported": ["RS256"],
  96. }
  97. elif url == JWKS_URI:
  98. return {"keys": []}
  99. class OidcHandlerTestCase(HomeserverTestCase):
  100. def default_config(self):
  101. config = super().default_config()
  102. config["public_baseurl"] = BASE_URL
  103. oidc_config = {
  104. "enabled": True,
  105. "client_id": CLIENT_ID,
  106. "client_secret": CLIENT_SECRET,
  107. "issuer": ISSUER,
  108. "scopes": SCOPES,
  109. "user_mapping_provider": {"module": __name__ + ".TestMappingProvider"},
  110. }
  111. # Update this config with what's in the default config so that
  112. # override_config works as expected.
  113. oidc_config.update(config.get("oidc_config", {}))
  114. config["oidc_config"] = oidc_config
  115. return config
  116. def make_homeserver(self, reactor, clock):
  117. self.http_client = Mock(spec=["get_json"])
  118. self.http_client.get_json.side_effect = get_json
  119. self.http_client.user_agent = "Synapse Test"
  120. hs = self.setup_test_homeserver(proxied_http_client=self.http_client)
  121. self.handler = hs.get_oidc_handler()
  122. sso_handler = hs.get_sso_handler()
  123. # Mock the render error method.
  124. self.render_error = Mock(return_value=None)
  125. sso_handler.render_error = self.render_error
  126. # Reduce the number of attempts when generating MXIDs.
  127. sso_handler._MAP_USERNAME_RETRIES = 3
  128. return hs
  129. def metadata_edit(self, values):
  130. return patch.dict(self.handler._provider_metadata, values)
  131. def assertRenderedError(self, error, error_description=None):
  132. args = self.render_error.call_args[0]
  133. self.assertEqual(args[1], error)
  134. if error_description is not None:
  135. self.assertEqual(args[2], error_description)
  136. # Reset the render_error mock
  137. self.render_error.reset_mock()
  138. def test_config(self):
  139. """Basic config correctly sets up the callback URL and client auth correctly."""
  140. self.assertEqual(self.handler._callback_url, CALLBACK_URL)
  141. self.assertEqual(self.handler._client_auth.client_id, CLIENT_ID)
  142. self.assertEqual(self.handler._client_auth.client_secret, CLIENT_SECRET)
  143. @override_config({"oidc_config": {"discover": True}})
  144. def test_discovery(self):
  145. """The handler should discover the endpoints from OIDC discovery document."""
  146. # This would throw if some metadata were invalid
  147. metadata = self.get_success(self.handler.load_metadata())
  148. self.http_client.get_json.assert_called_once_with(WELL_KNOWN)
  149. self.assertEqual(metadata.issuer, ISSUER)
  150. self.assertEqual(metadata.authorization_endpoint, AUTHORIZATION_ENDPOINT)
  151. self.assertEqual(metadata.token_endpoint, TOKEN_ENDPOINT)
  152. self.assertEqual(metadata.jwks_uri, JWKS_URI)
  153. # FIXME: it seems like authlib does not have that defined in its metadata models
  154. # self.assertEqual(metadata.userinfo_endpoint, USERINFO_ENDPOINT)
  155. # subsequent calls should be cached
  156. self.http_client.reset_mock()
  157. self.get_success(self.handler.load_metadata())
  158. self.http_client.get_json.assert_not_called()
  159. @override_config({"oidc_config": COMMON_CONFIG})
  160. def test_no_discovery(self):
  161. """When discovery is disabled, it should not try to load from discovery document."""
  162. self.get_success(self.handler.load_metadata())
  163. self.http_client.get_json.assert_not_called()
  164. @override_config({"oidc_config": COMMON_CONFIG})
  165. def test_load_jwks(self):
  166. """JWKS loading is done once (then cached) if used."""
  167. jwks = self.get_success(self.handler.load_jwks())
  168. self.http_client.get_json.assert_called_once_with(JWKS_URI)
  169. self.assertEqual(jwks, {"keys": []})
  170. # subsequent calls should be cached…
  171. self.http_client.reset_mock()
  172. self.get_success(self.handler.load_jwks())
  173. self.http_client.get_json.assert_not_called()
  174. # …unless forced
  175. self.http_client.reset_mock()
  176. self.get_success(self.handler.load_jwks(force=True))
  177. self.http_client.get_json.assert_called_once_with(JWKS_URI)
  178. # Throw if the JWKS uri is missing
  179. with self.metadata_edit({"jwks_uri": None}):
  180. self.get_failure(self.handler.load_jwks(force=True), RuntimeError)
  181. # Return empty key set if JWKS are not used
  182. self.handler._scopes = [] # not asking the openid scope
  183. self.http_client.get_json.reset_mock()
  184. jwks = self.get_success(self.handler.load_jwks(force=True))
  185. self.http_client.get_json.assert_not_called()
  186. self.assertEqual(jwks, {"keys": []})
  187. @override_config({"oidc_config": COMMON_CONFIG})
  188. def test_validate_config(self):
  189. """Provider metadatas are extensively validated."""
  190. h = self.handler
  191. # Default test config does not throw
  192. h._validate_metadata()
  193. with self.metadata_edit({"issuer": None}):
  194. self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata)
  195. with self.metadata_edit({"issuer": "http://insecure/"}):
  196. self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata)
  197. with self.metadata_edit({"issuer": "https://invalid/?because=query"}):
  198. self.assertRaisesRegex(ValueError, "issuer", h._validate_metadata)
  199. with self.metadata_edit({"authorization_endpoint": None}):
  200. self.assertRaisesRegex(
  201. ValueError, "authorization_endpoint", h._validate_metadata
  202. )
  203. with self.metadata_edit({"authorization_endpoint": "http://insecure/auth"}):
  204. self.assertRaisesRegex(
  205. ValueError, "authorization_endpoint", h._validate_metadata
  206. )
  207. with self.metadata_edit({"token_endpoint": None}):
  208. self.assertRaisesRegex(ValueError, "token_endpoint", h._validate_metadata)
  209. with self.metadata_edit({"token_endpoint": "http://insecure/token"}):
  210. self.assertRaisesRegex(ValueError, "token_endpoint", h._validate_metadata)
  211. with self.metadata_edit({"jwks_uri": None}):
  212. self.assertRaisesRegex(ValueError, "jwks_uri", h._validate_metadata)
  213. with self.metadata_edit({"jwks_uri": "http://insecure/jwks.json"}):
  214. self.assertRaisesRegex(ValueError, "jwks_uri", h._validate_metadata)
  215. with self.metadata_edit({"response_types_supported": ["id_token"]}):
  216. self.assertRaisesRegex(
  217. ValueError, "response_types_supported", h._validate_metadata
  218. )
  219. with self.metadata_edit(
  220. {"token_endpoint_auth_methods_supported": ["client_secret_basic"]}
  221. ):
  222. # should not throw, as client_secret_basic is the default auth method
  223. h._validate_metadata()
  224. with self.metadata_edit(
  225. {"token_endpoint_auth_methods_supported": ["client_secret_post"]}
  226. ):
  227. self.assertRaisesRegex(
  228. ValueError,
  229. "token_endpoint_auth_methods_supported",
  230. h._validate_metadata,
  231. )
  232. # Tests for configs that require the userinfo endpoint
  233. self.assertFalse(h._uses_userinfo)
  234. self.assertEqual(h._user_profile_method, "auto")
  235. h._user_profile_method = "userinfo_endpoint"
  236. self.assertTrue(h._uses_userinfo)
  237. # Revert the profile method and do not request the "openid" scope.
  238. h._user_profile_method = "auto"
  239. h._scopes = []
  240. self.assertTrue(h._uses_userinfo)
  241. self.assertRaisesRegex(ValueError, "userinfo_endpoint", h._validate_metadata)
  242. with self.metadata_edit(
  243. {"userinfo_endpoint": USERINFO_ENDPOINT, "jwks_uri": None}
  244. ):
  245. # Shouldn't raise with a valid userinfo, even without
  246. h._validate_metadata()
  247. @override_config({"oidc_config": {"skip_verification": True}})
  248. def test_skip_verification(self):
  249. """Provider metadata validation can be disabled by config."""
  250. with self.metadata_edit({"issuer": "http://insecure"}):
  251. # This should not throw
  252. self.handler._validate_metadata()
  253. def test_redirect_request(self):
  254. """The redirect request has the right arguments & generates a valid session cookie."""
  255. req = Mock(spec=["addCookie"])
  256. url = self.get_success(
  257. self.handler.handle_redirect_request(req, b"http://client/redirect")
  258. )
  259. url = urlparse(url)
  260. auth_endpoint = urlparse(AUTHORIZATION_ENDPOINT)
  261. self.assertEqual(url.scheme, auth_endpoint.scheme)
  262. self.assertEqual(url.netloc, auth_endpoint.netloc)
  263. self.assertEqual(url.path, auth_endpoint.path)
  264. params = parse_qs(url.query)
  265. self.assertEqual(params["redirect_uri"], [CALLBACK_URL])
  266. self.assertEqual(params["response_type"], ["code"])
  267. self.assertEqual(params["scope"], [" ".join(SCOPES)])
  268. self.assertEqual(params["client_id"], [CLIENT_ID])
  269. self.assertEqual(len(params["state"]), 1)
  270. self.assertEqual(len(params["nonce"]), 1)
  271. # Check what is in the cookie
  272. # note: python3.5 mock does not have the .called_once() method
  273. calls = req.addCookie.call_args_list
  274. self.assertEqual(len(calls), 1) # called once
  275. # For some reason, call.args does not work with python3.5
  276. args = calls[0][0]
  277. kwargs = calls[0][1]
  278. self.assertEqual(args[0], COOKIE_NAME)
  279. self.assertEqual(kwargs["path"], COOKIE_PATH)
  280. cookie = args[1]
  281. macaroon = pymacaroons.Macaroon.deserialize(cookie)
  282. state = self.handler._get_value_from_macaroon(macaroon, "state")
  283. nonce = self.handler._get_value_from_macaroon(macaroon, "nonce")
  284. redirect = self.handler._get_value_from_macaroon(
  285. macaroon, "client_redirect_url"
  286. )
  287. self.assertEqual(params["state"], [state])
  288. self.assertEqual(params["nonce"], [nonce])
  289. self.assertEqual(redirect, "http://client/redirect")
  290. def test_callback_error(self):
  291. """Errors from the provider returned in the callback are displayed."""
  292. request = Mock(args={})
  293. request.args[b"error"] = [b"invalid_client"]
  294. self.get_success(self.handler.handle_oidc_callback(request))
  295. self.assertRenderedError("invalid_client", "")
  296. request.args[b"error_description"] = [b"some description"]
  297. self.get_success(self.handler.handle_oidc_callback(request))
  298. self.assertRenderedError("invalid_client", "some description")
  299. def test_callback(self):
  300. """Code callback works and display errors if something went wrong.
  301. A lot of scenarios are tested here:
  302. - when the callback works, with userinfo from ID token
  303. - when the user mapping fails
  304. - when ID token verification fails
  305. - when the callback works, with userinfo fetched from the userinfo endpoint
  306. - when the userinfo fetching fails
  307. - when the code exchange fails
  308. """
  309. token = {
  310. "type": "bearer",
  311. "id_token": "id_token",
  312. "access_token": "access_token",
  313. }
  314. userinfo = {
  315. "sub": "foo",
  316. "preferred_username": "bar",
  317. }
  318. user_id = "@foo:domain.org"
  319. self.handler._exchange_code = simple_async_mock(return_value=token)
  320. self.handler._parse_id_token = simple_async_mock(return_value=userinfo)
  321. self.handler._fetch_userinfo = simple_async_mock(return_value=userinfo)
  322. self.handler._map_userinfo_to_user = simple_async_mock(return_value=user_id)
  323. self.handler._auth_handler.complete_sso_login = simple_async_mock()
  324. request = Mock(
  325. spec=[
  326. "args",
  327. "getCookie",
  328. "addCookie",
  329. "requestHeaders",
  330. "getClientIP",
  331. "get_user_agent",
  332. ]
  333. )
  334. code = "code"
  335. state = "state"
  336. nonce = "nonce"
  337. client_redirect_url = "http://client/redirect"
  338. user_agent = "Browser"
  339. ip_address = "10.0.0.1"
  340. request.getCookie.return_value = self.handler._generate_oidc_session_token(
  341. state=state,
  342. nonce=nonce,
  343. client_redirect_url=client_redirect_url,
  344. ui_auth_session_id=None,
  345. )
  346. request.args = {}
  347. request.args[b"code"] = [code.encode("utf-8")]
  348. request.args[b"state"] = [state.encode("utf-8")]
  349. request.getClientIP.return_value = ip_address
  350. request.get_user_agent.return_value = user_agent
  351. self.get_success(self.handler.handle_oidc_callback(request))
  352. self.handler._auth_handler.complete_sso_login.assert_called_once_with(
  353. user_id, request, client_redirect_url, {},
  354. )
  355. self.handler._exchange_code.assert_called_once_with(code)
  356. self.handler._parse_id_token.assert_called_once_with(token, nonce=nonce)
  357. self.handler._map_userinfo_to_user.assert_called_once_with(
  358. userinfo, token, user_agent, ip_address
  359. )
  360. self.handler._fetch_userinfo.assert_not_called()
  361. self.render_error.assert_not_called()
  362. # Handle mapping errors
  363. self.handler._map_userinfo_to_user = simple_async_mock(
  364. raises=MappingException()
  365. )
  366. self.get_success(self.handler.handle_oidc_callback(request))
  367. self.assertRenderedError("mapping_error")
  368. self.handler._map_userinfo_to_user = simple_async_mock(return_value=user_id)
  369. # Handle ID token errors
  370. self.handler._parse_id_token = simple_async_mock(raises=Exception())
  371. self.get_success(self.handler.handle_oidc_callback(request))
  372. self.assertRenderedError("invalid_token")
  373. self.handler._auth_handler.complete_sso_login.reset_mock()
  374. self.handler._exchange_code.reset_mock()
  375. self.handler._parse_id_token.reset_mock()
  376. self.handler._map_userinfo_to_user.reset_mock()
  377. self.handler._fetch_userinfo.reset_mock()
  378. # With userinfo fetching
  379. self.handler._scopes = [] # do not ask the "openid" scope
  380. self.get_success(self.handler.handle_oidc_callback(request))
  381. self.handler._auth_handler.complete_sso_login.assert_called_once_with(
  382. user_id, request, client_redirect_url, {},
  383. )
  384. self.handler._exchange_code.assert_called_once_with(code)
  385. self.handler._parse_id_token.assert_not_called()
  386. self.handler._map_userinfo_to_user.assert_called_once_with(
  387. userinfo, token, user_agent, ip_address
  388. )
  389. self.handler._fetch_userinfo.assert_called_once_with(token)
  390. self.render_error.assert_not_called()
  391. # Handle userinfo fetching error
  392. self.handler._fetch_userinfo = simple_async_mock(raises=Exception())
  393. self.get_success(self.handler.handle_oidc_callback(request))
  394. self.assertRenderedError("fetch_error")
  395. # Handle code exchange failure
  396. self.handler._exchange_code = simple_async_mock(
  397. raises=OidcError("invalid_request")
  398. )
  399. self.get_success(self.handler.handle_oidc_callback(request))
  400. self.assertRenderedError("invalid_request")
  401. def test_callback_session(self):
  402. """The callback verifies the session presence and validity"""
  403. request = Mock(spec=["args", "getCookie", "addCookie"])
  404. # Missing cookie
  405. request.args = {}
  406. request.getCookie.return_value = None
  407. self.get_success(self.handler.handle_oidc_callback(request))
  408. self.assertRenderedError("missing_session", "No session cookie found")
  409. # Missing session parameter
  410. request.args = {}
  411. request.getCookie.return_value = "session"
  412. self.get_success(self.handler.handle_oidc_callback(request))
  413. self.assertRenderedError("invalid_request", "State parameter is missing")
  414. # Invalid cookie
  415. request.args = {}
  416. request.args[b"state"] = [b"state"]
  417. request.getCookie.return_value = "session"
  418. self.get_success(self.handler.handle_oidc_callback(request))
  419. self.assertRenderedError("invalid_session")
  420. # Mismatching session
  421. session = self.handler._generate_oidc_session_token(
  422. state="state",
  423. nonce="nonce",
  424. client_redirect_url="http://client/redirect",
  425. ui_auth_session_id=None,
  426. )
  427. request.args = {}
  428. request.args[b"state"] = [b"mismatching state"]
  429. request.getCookie.return_value = session
  430. self.get_success(self.handler.handle_oidc_callback(request))
  431. self.assertRenderedError("mismatching_session")
  432. # Valid session
  433. request.args = {}
  434. request.args[b"state"] = [b"state"]
  435. request.getCookie.return_value = session
  436. self.get_success(self.handler.handle_oidc_callback(request))
  437. self.assertRenderedError("invalid_request")
  438. @override_config({"oidc_config": {"client_auth_method": "client_secret_post"}})
  439. def test_exchange_code(self):
  440. """Code exchange behaves correctly and handles various error scenarios."""
  441. token = {"type": "bearer"}
  442. token_json = json.dumps(token).encode("utf-8")
  443. self.http_client.request = simple_async_mock(
  444. return_value=FakeResponse(code=200, phrase=b"OK", body=token_json)
  445. )
  446. code = "code"
  447. ret = self.get_success(self.handler._exchange_code(code))
  448. kwargs = self.http_client.request.call_args[1]
  449. self.assertEqual(ret, token)
  450. self.assertEqual(kwargs["method"], "POST")
  451. self.assertEqual(kwargs["uri"], TOKEN_ENDPOINT)
  452. args = parse_qs(kwargs["data"].decode("utf-8"))
  453. self.assertEqual(args["grant_type"], ["authorization_code"])
  454. self.assertEqual(args["code"], [code])
  455. self.assertEqual(args["client_id"], [CLIENT_ID])
  456. self.assertEqual(args["client_secret"], [CLIENT_SECRET])
  457. self.assertEqual(args["redirect_uri"], [CALLBACK_URL])
  458. # Test error handling
  459. self.http_client.request = simple_async_mock(
  460. return_value=FakeResponse(
  461. code=400,
  462. phrase=b"Bad Request",
  463. body=b'{"error": "foo", "error_description": "bar"}',
  464. )
  465. )
  466. exc = self.get_failure(self.handler._exchange_code(code), OidcError)
  467. self.assertEqual(exc.value.error, "foo")
  468. self.assertEqual(exc.value.error_description, "bar")
  469. # Internal server error with no JSON body
  470. self.http_client.request = simple_async_mock(
  471. return_value=FakeResponse(
  472. code=500, phrase=b"Internal Server Error", body=b"Not JSON",
  473. )
  474. )
  475. exc = self.get_failure(self.handler._exchange_code(code), OidcError)
  476. self.assertEqual(exc.value.error, "server_error")
  477. # Internal server error with JSON body
  478. self.http_client.request = simple_async_mock(
  479. return_value=FakeResponse(
  480. code=500,
  481. phrase=b"Internal Server Error",
  482. body=b'{"error": "internal_server_error"}',
  483. )
  484. )
  485. exc = self.get_failure(self.handler._exchange_code(code), OidcError)
  486. self.assertEqual(exc.value.error, "internal_server_error")
  487. # 4xx error without "error" field
  488. self.http_client.request = simple_async_mock(
  489. return_value=FakeResponse(code=400, phrase=b"Bad request", body=b"{}",)
  490. )
  491. exc = self.get_failure(self.handler._exchange_code(code), OidcError)
  492. self.assertEqual(exc.value.error, "server_error")
  493. # 2xx error with "error" field
  494. self.http_client.request = simple_async_mock(
  495. return_value=FakeResponse(
  496. code=200, phrase=b"OK", body=b'{"error": "some_error"}',
  497. )
  498. )
  499. exc = self.get_failure(self.handler._exchange_code(code), OidcError)
  500. self.assertEqual(exc.value.error, "some_error")
  501. @override_config(
  502. {
  503. "oidc_config": {
  504. "user_mapping_provider": {
  505. "module": __name__ + ".TestMappingProviderExtra"
  506. }
  507. }
  508. }
  509. )
  510. def test_extra_attributes(self):
  511. """
  512. Login while using a mapping provider that implements get_extra_attributes.
  513. """
  514. token = {
  515. "type": "bearer",
  516. "id_token": "id_token",
  517. "access_token": "access_token",
  518. }
  519. userinfo = {
  520. "sub": "foo",
  521. "phone": "1234567",
  522. }
  523. user_id = "@foo:domain.org"
  524. self.handler._exchange_code = simple_async_mock(return_value=token)
  525. self.handler._parse_id_token = simple_async_mock(return_value=userinfo)
  526. self.handler._map_userinfo_to_user = simple_async_mock(return_value=user_id)
  527. self.handler._auth_handler.complete_sso_login = simple_async_mock()
  528. request = Mock(
  529. spec=[
  530. "args",
  531. "getCookie",
  532. "addCookie",
  533. "requestHeaders",
  534. "getClientIP",
  535. "get_user_agent",
  536. ]
  537. )
  538. state = "state"
  539. client_redirect_url = "http://client/redirect"
  540. request.getCookie.return_value = self.handler._generate_oidc_session_token(
  541. state=state,
  542. nonce="nonce",
  543. client_redirect_url=client_redirect_url,
  544. ui_auth_session_id=None,
  545. )
  546. request.args = {}
  547. request.args[b"code"] = [b"code"]
  548. request.args[b"state"] = [state.encode("utf-8")]
  549. request.getClientIP.return_value = "10.0.0.1"
  550. request.get_user_agent.return_value = "Browser"
  551. self.get_success(self.handler.handle_oidc_callback(request))
  552. self.handler._auth_handler.complete_sso_login.assert_called_once_with(
  553. user_id, request, client_redirect_url, {"phone": "1234567"},
  554. )
  555. def test_map_userinfo_to_user(self):
  556. """Ensure that mapping the userinfo returned from a provider to an MXID works properly."""
  557. userinfo = {
  558. "sub": "test_user",
  559. "username": "test_user",
  560. }
  561. # The token doesn't matter with the default user mapping provider.
  562. token = {}
  563. mxid = self.get_success(
  564. self.handler._map_userinfo_to_user(
  565. userinfo, token, "user-agent", "10.10.10.10"
  566. )
  567. )
  568. self.assertEqual(mxid, "@test_user:test")
  569. # Some providers return an integer ID.
  570. userinfo = {
  571. "sub": 1234,
  572. "username": "test_user_2",
  573. }
  574. mxid = self.get_success(
  575. self.handler._map_userinfo_to_user(
  576. userinfo, token, "user-agent", "10.10.10.10"
  577. )
  578. )
  579. self.assertEqual(mxid, "@test_user_2:test")
  580. # Test if the mxid is already taken
  581. store = self.hs.get_datastore()
  582. user3 = UserID.from_string("@test_user_3:test")
  583. self.get_success(
  584. store.register_user(user_id=user3.to_string(), password_hash=None)
  585. )
  586. userinfo = {"sub": "test3", "username": "test_user_3"}
  587. e = self.get_failure(
  588. self.handler._map_userinfo_to_user(
  589. userinfo, token, "user-agent", "10.10.10.10"
  590. ),
  591. MappingException,
  592. )
  593. self.assertEqual(
  594. str(e.value), "Mapping provider does not support de-duplicating Matrix IDs",
  595. )
  596. @override_config({"oidc_config": {"allow_existing_users": True}})
  597. def test_map_userinfo_to_existing_user(self):
  598. """Existing users can log in with OpenID Connect when allow_existing_users is True."""
  599. store = self.hs.get_datastore()
  600. user = UserID.from_string("@test_user:test")
  601. self.get_success(
  602. store.register_user(user_id=user.to_string(), password_hash=None)
  603. )
  604. # Map a user via SSO.
  605. userinfo = {
  606. "sub": "test",
  607. "username": "test_user",
  608. }
  609. token = {}
  610. mxid = self.get_success(
  611. self.handler._map_userinfo_to_user(
  612. userinfo, token, "user-agent", "10.10.10.10"
  613. )
  614. )
  615. self.assertEqual(mxid, "@test_user:test")
  616. # Subsequent calls should map to the same mxid.
  617. mxid = self.get_success(
  618. self.handler._map_userinfo_to_user(
  619. userinfo, token, "user-agent", "10.10.10.10"
  620. )
  621. )
  622. self.assertEqual(mxid, "@test_user:test")
  623. # Note that a second SSO user can be mapped to the same Matrix ID. (This
  624. # requires a unique sub, but something that maps to the same matrix ID,
  625. # in this case we'll just use the same username. A more realistic example
  626. # would be subs which are email addresses, and mapping from the localpart
  627. # of the email, e.g. bob@foo.com and bob@bar.com -> @bob:test.)
  628. userinfo = {
  629. "sub": "test1",
  630. "username": "test_user",
  631. }
  632. token = {}
  633. mxid = self.get_success(
  634. self.handler._map_userinfo_to_user(
  635. userinfo, token, "user-agent", "10.10.10.10"
  636. )
  637. )
  638. self.assertEqual(mxid, "@test_user:test")
  639. # Register some non-exact matching cases.
  640. user2 = UserID.from_string("@TEST_user_2:test")
  641. self.get_success(
  642. store.register_user(user_id=user2.to_string(), password_hash=None)
  643. )
  644. user2_caps = UserID.from_string("@test_USER_2:test")
  645. self.get_success(
  646. store.register_user(user_id=user2_caps.to_string(), password_hash=None)
  647. )
  648. # Attempting to login without matching a name exactly is an error.
  649. userinfo = {
  650. "sub": "test2",
  651. "username": "TEST_USER_2",
  652. }
  653. e = self.get_failure(
  654. self.handler._map_userinfo_to_user(
  655. userinfo, token, "user-agent", "10.10.10.10"
  656. ),
  657. MappingException,
  658. )
  659. self.assertTrue(
  660. str(e.value).startswith(
  661. "Attempted to login as '@TEST_USER_2:test' but it matches more than one user inexactly:"
  662. )
  663. )
  664. # Logging in when matching a name exactly should work.
  665. user2 = UserID.from_string("@TEST_USER_2:test")
  666. self.get_success(
  667. store.register_user(user_id=user2.to_string(), password_hash=None)
  668. )
  669. mxid = self.get_success(
  670. self.handler._map_userinfo_to_user(
  671. userinfo, token, "user-agent", "10.10.10.10"
  672. )
  673. )
  674. self.assertEqual(mxid, "@TEST_USER_2:test")
  675. def test_map_userinfo_to_invalid_localpart(self):
  676. """If the mapping provider generates an invalid localpart it should be rejected."""
  677. userinfo = {
  678. "sub": "test2",
  679. "username": "föö",
  680. }
  681. token = {}
  682. e = self.get_failure(
  683. self.handler._map_userinfo_to_user(
  684. userinfo, token, "user-agent", "10.10.10.10"
  685. ),
  686. MappingException,
  687. )
  688. self.assertEqual(str(e.value), "localpart is invalid: föö")
  689. @override_config(
  690. {
  691. "oidc_config": {
  692. "user_mapping_provider": {
  693. "module": __name__ + ".TestMappingProviderFailures"
  694. }
  695. }
  696. }
  697. )
  698. def test_map_userinfo_to_user_retries(self):
  699. """The mapping provider can retry generating an MXID if the MXID is already in use."""
  700. store = self.hs.get_datastore()
  701. self.get_success(
  702. store.register_user(user_id="@test_user:test", password_hash=None)
  703. )
  704. userinfo = {
  705. "sub": "test",
  706. "username": "test_user",
  707. }
  708. token = {}
  709. mxid = self.get_success(
  710. self.handler._map_userinfo_to_user(
  711. userinfo, token, "user-agent", "10.10.10.10"
  712. )
  713. )
  714. # test_user is already taken, so test_user1 gets registered instead.
  715. self.assertEqual(mxid, "@test_user1:test")
  716. # Register all of the potential mxids for a particular OIDC username.
  717. self.get_success(
  718. store.register_user(user_id="@tester:test", password_hash=None)
  719. )
  720. for i in range(1, 3):
  721. self.get_success(
  722. store.register_user(user_id="@tester%d:test" % i, password_hash=None)
  723. )
  724. # Now attempt to map to a username, this will fail since all potential usernames are taken.
  725. userinfo = {
  726. "sub": "tester",
  727. "username": "tester",
  728. }
  729. e = self.get_failure(
  730. self.handler._map_userinfo_to_user(
  731. userinfo, token, "user-agent", "10.10.10.10"
  732. ),
  733. MappingException,
  734. )
  735. self.assertEqual(
  736. str(e.value), "Unable to generate a Matrix ID from the SSO response"
  737. )