test_oidc.py 35 KB

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