Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 0dd2649

Browse files
authored
Improve UsernamePickerTestCase (#9112)
* make the OIDC bits of the test work at a higher level - via the REST api instead of poking the OIDCHandler directly. * Move it to test_login.py, where I think it fits better.
1 parent 4575ad0 commit 0dd2649

File tree

5 files changed

+114
-125
lines changed

5 files changed

+114
-125
lines changed

changelog.d/9112.misc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Improve `UsernamePickerTestCase`.

tests/handlers/test_oidc.py

Lines changed: 2 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,14 @@
1313
# See the License for the specific language governing permissions and
1414
# limitations under the License.
1515
import json
16-
import re
17-
from typing import Dict, Optional
18-
from urllib.parse import parse_qs, urlencode, urlparse
16+
from typing import Optional
17+
from urllib.parse import parse_qs, urlparse
1918

2019
from mock import ANY, Mock, patch
2120

2221
import pymacaroons
2322

24-
from twisted.web.resource import Resource
25-
26-
from synapse.api.errors import RedirectException
2723
from synapse.handlers.sso import MappingException
28-
from synapse.rest.client.v1 import login
29-
from synapse.rest.synapse.client.pick_username import pick_username_resource
3024
from synapse.server import HomeServer
3125
from synapse.types import UserID
3226

@@ -856,116 +850,6 @@ def _generate_oidc_session_token(
856850
)
857851

858852

859-
class UsernamePickerTestCase(HomeserverTestCase):
860-
if not HAS_OIDC:
861-
skip = "requires OIDC"
862-
863-
servlets = [login.register_servlets]
864-
865-
def default_config(self):
866-
config = super().default_config()
867-
config["public_baseurl"] = BASE_URL
868-
oidc_config = {
869-
"enabled": True,
870-
"client_id": CLIENT_ID,
871-
"client_secret": CLIENT_SECRET,
872-
"issuer": ISSUER,
873-
"scopes": SCOPES,
874-
"user_mapping_provider": {
875-
"config": {"display_name_template": "{{ user.displayname }}"}
876-
},
877-
}
878-
879-
# Update this config with what's in the default config so that
880-
# override_config works as expected.
881-
oidc_config.update(config.get("oidc_config", {}))
882-
config["oidc_config"] = oidc_config
883-
884-
# whitelist this client URI so we redirect straight to it rather than
885-
# serving a confirmation page
886-
config["sso"] = {"client_whitelist": ["https://whitelisted.client"]}
887-
return config
888-
889-
def create_resource_dict(self) -> Dict[str, Resource]:
890-
d = super().create_resource_dict()
891-
d["/_synapse/client/pick_username"] = pick_username_resource(self.hs)
892-
return d
893-
894-
def test_username_picker(self):
895-
"""Test the happy path of a username picker flow."""
896-
client_redirect_url = "https://whitelisted.client"
897-
898-
# first of all, mock up an OIDC callback to the OidcHandler, which should
899-
# raise a RedirectException
900-
userinfo = {"sub": "tester", "displayname": "Jonny"}
901-
f = self.get_failure(
902-
_make_callback_with_userinfo(
903-
self.hs, userinfo, client_redirect_url=client_redirect_url
904-
),
905-
RedirectException,
906-
)
907-
908-
# check the Location and cookies returned by the RedirectException
909-
self.assertEqual(f.value.location, b"/_synapse/client/pick_username")
910-
cookieheader = f.value.cookies[0]
911-
regex = re.compile(b"^username_mapping_session=([a-zA-Z]+);")
912-
m = regex.search(cookieheader)
913-
if not m:
914-
self.fail("cookie header %s does not match %s" % (cookieheader, regex))
915-
916-
# introspect the sso handler a bit to check that the username mapping session
917-
# looks ok.
918-
session_id = m.group(1).decode("ascii")
919-
username_mapping_sessions = self.hs.get_sso_handler()._username_mapping_sessions
920-
self.assertIn(
921-
session_id, username_mapping_sessions, "session id not found in map"
922-
)
923-
session = username_mapping_sessions[session_id]
924-
self.assertEqual(session.remote_user_id, "tester")
925-
self.assertEqual(session.display_name, "Jonny")
926-
self.assertEqual(session.client_redirect_url, client_redirect_url)
927-
928-
# the expiry time should be about 15 minutes away
929-
expected_expiry = self.clock.time_msec() + (15 * 60 * 1000)
930-
self.assertApproximates(session.expiry_time_ms, expected_expiry, tolerance=1000)
931-
932-
# Now, submit a username to the username picker, which should serve a redirect
933-
# back to the client
934-
submit_path = f.value.location + b"/submit"
935-
content = urlencode({b"username": b"bobby"}).encode("utf8")
936-
chan = self.make_request(
937-
"POST",
938-
path=submit_path,
939-
content=content,
940-
content_is_form=True,
941-
custom_headers=[
942-
("Cookie", cookieheader),
943-
# old versions of twisted don't do form-parsing without a valid
944-
# content-length header.
945-
("Content-Length", str(len(content))),
946-
],
947-
)
948-
self.assertEqual(chan.code, 302, chan.result)
949-
location_headers = chan.headers.getRawHeaders("Location")
950-
# ensure that the returned location starts with the requested redirect URL
951-
self.assertEqual(
952-
location_headers[0][: len(client_redirect_url)], client_redirect_url
953-
)
954-
955-
# fish the login token out of the returned redirect uri
956-
parts = urlparse(location_headers[0])
957-
query = parse_qs(parts.query)
958-
login_token = query["loginToken"][0]
959-
960-
# finally, submit the matrix login token to the login API, which gives us our
961-
# matrix access token, mxid, and device id.
962-
chan = self.make_request(
963-
"POST", "/login", content={"type": "m.login.token", "token": login_token},
964-
)
965-
self.assertEqual(chan.code, 200, chan.result)
966-
self.assertEqual(chan.json_body["user_id"], "@bobby:test")
967-
968-
969853
async def _make_callback_with_userinfo(
970854
hs: HomeServer, userinfo: dict, client_redirect_url: str = "http://client/redirect"
971855
) -> None:

tests/rest/client/v1/test_login.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import urllib.parse
1818
from html.parser import HTMLParser
1919
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
20+
from urllib.parse import parse_qs, urlencode, urlparse
2021

2122
from mock import Mock
2223

@@ -30,13 +31,14 @@
3031
from synapse.rest.client.v2_alpha import devices, register
3132
from synapse.rest.client.v2_alpha.account import WhoamiRestServlet
3233
from synapse.rest.synapse.client.pick_idp import PickIdpResource
34+
from synapse.rest.synapse.client.pick_username import pick_username_resource
3335
from synapse.types import create_requester
3436

3537
from tests import unittest
3638
from tests.handlers.test_oidc import HAS_OIDC
3739
from tests.handlers.test_saml import has_saml2
3840
from tests.rest.client.v1.utils import TEST_OIDC_AUTH_ENDPOINT, TEST_OIDC_CONFIG
39-
from tests.unittest import override_config, skip_unless
41+
from tests.unittest import HomeserverTestCase, override_config, skip_unless
4042

4143
try:
4244
import jwt
@@ -1060,3 +1062,104 @@ def test_login_appservice_no_token(self):
10601062
channel = self.make_request(b"POST", LOGIN_URL, params)
10611063

10621064
self.assertEquals(channel.result["code"], b"401", channel.result)
1065+
1066+
1067+
@skip_unless(HAS_OIDC, "requires OIDC")
1068+
class UsernamePickerTestCase(HomeserverTestCase):
1069+
"""Tests for the username picker flow of SSO login"""
1070+
1071+
servlets = [login.register_servlets]
1072+
1073+
def default_config(self):
1074+
config = super().default_config()
1075+
config["public_baseurl"] = BASE_URL
1076+
1077+
config["oidc_config"] = {}
1078+
config["oidc_config"].update(TEST_OIDC_CONFIG)
1079+
config["oidc_config"]["user_mapping_provider"] = {
1080+
"config": {"display_name_template": "{{ user.displayname }}"}
1081+
}
1082+
1083+
# whitelist this client URI so we redirect straight to it rather than
1084+
# serving a confirmation page
1085+
config["sso"] = {"client_whitelist": ["https://whitelisted.client"]}
1086+
return config
1087+
1088+
def create_resource_dict(self) -> Dict[str, Resource]:
1089+
from synapse.rest.oidc import OIDCResource
1090+
1091+
d = super().create_resource_dict()
1092+
d["/_synapse/client/pick_username"] = pick_username_resource(self.hs)
1093+
d["/_synapse/oidc"] = OIDCResource(self.hs)
1094+
return d
1095+
1096+
def test_username_picker(self):
1097+
"""Test the happy path of a username picker flow."""
1098+
client_redirect_url = "https://whitelisted.client"
1099+
1100+
# do the start of the login flow
1101+
channel = self.helper.auth_via_oidc(
1102+
{"sub": "tester", "displayname": "Jonny"}, client_redirect_url
1103+
)
1104+
1105+
# that should redirect to the username picker
1106+
self.assertEqual(channel.code, 302, channel.result)
1107+
picker_url = channel.headers.getRawHeaders("Location")[0]
1108+
self.assertEqual(picker_url, "/_synapse/client/pick_username")
1109+
1110+
# ... with a username_mapping_session cookie
1111+
cookies = {} # type: Dict[str,str]
1112+
channel.extract_cookies(cookies)
1113+
self.assertIn("username_mapping_session", cookies)
1114+
session_id = cookies["username_mapping_session"]
1115+
1116+
# introspect the sso handler a bit to check that the username mapping session
1117+
# looks ok.
1118+
username_mapping_sessions = self.hs.get_sso_handler()._username_mapping_sessions
1119+
self.assertIn(
1120+
session_id, username_mapping_sessions, "session id not found in map",
1121+
)
1122+
session = username_mapping_sessions[session_id]
1123+
self.assertEqual(session.remote_user_id, "tester")
1124+
self.assertEqual(session.display_name, "Jonny")
1125+
self.assertEqual(session.client_redirect_url, client_redirect_url)
1126+
1127+
# the expiry time should be about 15 minutes away
1128+
expected_expiry = self.clock.time_msec() + (15 * 60 * 1000)
1129+
self.assertApproximates(session.expiry_time_ms, expected_expiry, tolerance=1000)
1130+
1131+
# Now, submit a username to the username picker, which should serve a redirect
1132+
# back to the client
1133+
submit_path = picker_url + "/submit"
1134+
content = urlencode({b"username": b"bobby"}).encode("utf8")
1135+
chan = self.make_request(
1136+
"POST",
1137+
path=submit_path,
1138+
content=content,
1139+
content_is_form=True,
1140+
custom_headers=[
1141+
("Cookie", "username_mapping_session=" + session_id),
1142+
# old versions of twisted don't do form-parsing without a valid
1143+
# content-length header.
1144+
("Content-Length", str(len(content))),
1145+
],
1146+
)
1147+
self.assertEqual(chan.code, 302, chan.result)
1148+
location_headers = chan.headers.getRawHeaders("Location")
1149+
# ensure that the returned location starts with the requested redirect URL
1150+
self.assertEqual(
1151+
location_headers[0][: len(client_redirect_url)], client_redirect_url
1152+
)
1153+
1154+
# fish the login token out of the returned redirect uri
1155+
parts = urlparse(location_headers[0])
1156+
query = parse_qs(parts.query)
1157+
login_token = query["loginToken"][0]
1158+
1159+
# finally, submit the matrix login token to the login API, which gives us our
1160+
# matrix access token, mxid, and device id.
1161+
chan = self.make_request(
1162+
"POST", "/login", content={"type": "m.login.token", "token": login_token},
1163+
)
1164+
self.assertEqual(chan.code, 200, chan.result)
1165+
self.assertEqual(chan.json_body["user_id"], "@bobby:test")

tests/rest/client/v1/utils.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -363,10 +363,10 @@ def login_via_oidc(self, remote_user_id: str) -> JsonDict:
363363
the normal places.
364364
"""
365365
client_redirect_url = "https://x"
366-
channel = self.auth_via_oidc(remote_user_id, client_redirect_url)
366+
channel = self.auth_via_oidc({"sub": remote_user_id}, client_redirect_url)
367367

368368
# expect a confirmation page
369-
assert channel.code == 200
369+
assert channel.code == 200, channel.result
370370

371371
# fish the matrix login token out of the body of the confirmation page
372372
m = re.search(
@@ -390,7 +390,7 @@ def login_via_oidc(self, remote_user_id: str) -> JsonDict:
390390

391391
def auth_via_oidc(
392392
self,
393-
remote_user_id: str,
393+
user_info_dict: JsonDict,
394394
client_redirect_url: Optional[str] = None,
395395
ui_auth_session_id: Optional[str] = None,
396396
) -> FakeChannel:
@@ -411,7 +411,8 @@ def auth_via_oidc(
411411
the normal places.
412412
413413
Args:
414-
remote_user_id: the remote id that the OIDC provider should present
414+
user_info_dict: the remote userinfo that the OIDC provider should present.
415+
Typically this should be '{"sub": "<remote user id>"}'.
415416
client_redirect_url: for a login flow, the client redirect URL to pass to
416417
the login redirect endpoint
417418
ui_auth_session_id: if set, we will perform a UI Auth flow. The session id
@@ -457,7 +458,7 @@ def auth_via_oidc(
457458
# a dummy OIDC access token
458459
("https://issuer.test/token", {"access_token": "TEST"}),
459460
# and then one to the user_info endpoint, which returns our remote user id.
460-
("https://issuer.test/userinfo", {"sub": remote_user_id}),
461+
("https://issuer.test/userinfo", user_info_dict),
461462
]
462463

463464
async def mock_req(method: str, uri: str, data=None, headers=None):

tests/rest/client/v2_alpha/test_auth.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,7 @@ def test_ui_auth_via_sso(self):
411411
# run the UIA-via-SSO flow
412412
session_id = channel.json_body["session"]
413413
channel = self.helper.auth_via_oidc(
414-
remote_user_id=remote_user_id, ui_auth_session_id=session_id
414+
{"sub": remote_user_id}, ui_auth_session_id=session_id
415415
)
416416

417417
# that should serve a confirmation page

0 commit comments

Comments
 (0)