-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Add a test for UI-Auth-via-SSO #9082
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Add support for multiple SSO Identity Providers. |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,7 +2,7 @@ | |
# Copyright 2014-2016 OpenMarket Ltd | ||
# Copyright 2017 Vector Creations Ltd | ||
# Copyright 2018-2019 New Vector Ltd | ||
# Copyright 2019-2020 The Matrix.org Foundation C.I.C. | ||
# Copyright 2019-2021 The Matrix.org Foundation C.I.C. | ||
# | ||
# Licensed under the Apache License, Version 2.0 (the "License"); | ||
# you may not use this file except in compliance with the License. | ||
|
@@ -20,7 +20,8 @@ | |
import re | ||
import time | ||
import urllib.parse | ||
from typing import Any, Dict, Optional | ||
from html.parser import HTMLParser | ||
from typing import Any, Dict, Iterable, List, MutableMapping, Optional, Tuple | ||
|
||
from mock import patch | ||
|
||
|
@@ -32,7 +33,7 @@ | |
from synapse.api.constants import Membership | ||
from synapse.types import JsonDict | ||
|
||
from tests.server import FakeSite, make_request | ||
from tests.server import FakeChannel, FakeSite, make_request | ||
from tests.test_utils import FakeResponse | ||
|
||
|
||
|
@@ -362,34 +363,93 @@ def login_via_oidc(self, remote_user_id: str) -> JsonDict: | |
the normal places. | ||
""" | ||
client_redirect_url = "https://x" | ||
channel = self.auth_via_oidc(remote_user_id, client_redirect_url) | ||
|
||
# first hit the redirect url (which will issue a cookie and state) | ||
# expect a confirmation page | ||
assert channel.code == 200 | ||
|
||
# fish the matrix login token out of the body of the confirmation page | ||
m = re.search( | ||
'a href="%s.*loginToken=([^"]*)"' % (client_redirect_url,), | ||
channel.text_body, | ||
) | ||
assert m, channel.text_body | ||
login_token = m.group(1) | ||
|
||
# finally, submit the matrix login token to the login API, which gives us our | ||
# matrix access token and device id. | ||
channel = make_request( | ||
self.hs.get_reactor(), | ||
self.site, | ||
"GET", | ||
"/login/sso/redirect?redirectUrl=" + client_redirect_url, | ||
"POST", | ||
"/login", | ||
content={"type": "m.login.token", "token": login_token}, | ||
) | ||
# that will redirect to the OIDC IdP, but we skip that and go straight | ||
assert channel.code == 200 | ||
return channel.json_body | ||
|
||
def auth_via_oidc( | ||
self, | ||
remote_user_id: str, | ||
client_redirect_url: Optional[str] = None, | ||
ui_auth_session_id: Optional[str] = None, | ||
) -> FakeChannel: | ||
"""Perform an OIDC authentication flow via a mock OIDC provider. | ||
|
||
This can be used for either login or user-interactive auth. | ||
|
||
Starts by making a request to the relevant synapse redirect endpoint, which is | ||
expected to serve a 302 to the OIDC provider. We then make a request to the | ||
OIDC callback endpoint, intercepting the HTTP requests that will get sent back | ||
to the OIDC provider. | ||
|
||
Requires that "oidc_config" in the homeserver config be set appropriately | ||
(TEST_OIDC_CONFIG is a suitable example) - and by implication, needs a | ||
"public_base_url". | ||
|
||
Also requires the login servlet and the OIDC callback resource to be mounted at | ||
the normal places. | ||
|
||
Args: | ||
remote_user_id: the remote id that the OIDC provider should present | ||
client_redirect_url: for a login flow, the client redirect URL to pass to | ||
the login redirect endpoint | ||
ui_auth_session_id: if set, we will perform a UI Auth flow. The session id | ||
of the UI auth. | ||
|
||
Returns: | ||
the result of calling the OIDC callback endpoint - which may be a 200, 302 | ||
or 400 depending on how things went. | ||
""" | ||
|
||
cookies = {} | ||
|
||
# if we're doing a ui auth, hit the ui auth redirect endpoint | ||
if ui_auth_session_id: | ||
# can't set the client redirect url for UI Auth | ||
assert client_redirect_url is None | ||
oauth_uri = self.initiate_sso_ui_auth(ui_auth_session_id, cookies) | ||
else: | ||
# otherwise, hit the login redirect endpoint | ||
oauth_uri = self.initiate_sso_login(client_redirect_url, cookies) | ||
|
||
# we now have a URI for the OIDC IdP, but we skip that and go straight | ||
# back to synapse's OIDC callback resource. However, we do need the "state" | ||
# param that synapse passes to the IdP via query params, and the cookie that | ||
# synapse passes to the client. | ||
assert channel.code == 302 | ||
oauth_uri = channel.headers.getRawHeaders("Location")[0] | ||
params = urllib.parse.parse_qs(urllib.parse.urlparse(oauth_uri).query) | ||
redirect_uri = "%s?%s" % ( | ||
# param that synapse passes to the IdP via query params, as well as the cookie | ||
# that synapse passes to the client. | ||
|
||
oauth_uri_path, oauth_uri_qs = oauth_uri.split("?", 1) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. My brain says this should use There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I flipflopped on that a bit, but basically I don't really want to have to reassemble bits of the urlparse output to compare against |
||
assert oauth_uri_path == TEST_OIDC_AUTH_ENDPOINT, ( | ||
"unexpected SSO URI " + oauth_uri_path | ||
) | ||
params = urllib.parse.parse_qs(oauth_uri_qs) | ||
callback_uri = "%s?%s" % ( | ||
urllib.parse.urlparse(params["redirect_uri"][0]).path, | ||
urllib.parse.urlencode({"state": params["state"][0], "code": "TEST_CODE"}), | ||
) | ||
cookies = {} | ||
for h in channel.headers.getRawHeaders("Set-Cookie"): | ||
parts = h.split(";") | ||
k, v = parts[0].split("=", maxsplit=1) | ||
cookies[k] = v | ||
|
||
# before we hit the callback uri, stub out some methods in the http client so | ||
# that we don't have to handle full HTTPS requests. | ||
|
||
# (expected url, json response) pairs, in the order we expect them. | ||
expected_requests = [ | ||
# first we get a hit to the token endpoint, which we tell to return | ||
|
@@ -413,34 +473,97 @@ async def mock_req(method: str, uri: str, data=None, headers=None): | |
self.hs.get_reactor(), | ||
self.site, | ||
"GET", | ||
redirect_uri, | ||
callback_uri, | ||
custom_headers=[ | ||
("Cookie", "%s=%s" % (k, v)) for (k, v) in cookies.items() | ||
], | ||
) | ||
return channel | ||
|
||
# expect a confirmation page | ||
assert channel.code == 200 | ||
def initiate_sso_login( | ||
self, client_redirect_url: Optional[str], cookies: MutableMapping[str, str] | ||
) -> str: | ||
"""Make a request to the login-via-sso redirect endpoint, and return the target | ||
|
||
# fish the matrix login token out of the body of the confirmation page | ||
m = re.search( | ||
'a href="%s.*loginToken=([^"]*)"' % (client_redirect_url,), | ||
channel.result["body"].decode("utf-8"), | ||
) | ||
assert m | ||
login_token = m.group(1) | ||
Assumes that exactly one SSO provider has been configured. Requires the login | ||
servlet to be mounted. | ||
|
||
# finally, submit the matrix login token to the login API, which gives us our | ||
# matrix access token and device id. | ||
Args: | ||
client_redirect_url: the client redirect URL to pass to the login redirect | ||
endpoint | ||
cookies: any cookies returned will be added to this dict | ||
|
||
Returns: | ||
the URI that the client gets redirected to (ie, the SSO server) | ||
""" | ||
params = {} | ||
if client_redirect_url: | ||
params["redirectUrl"] = client_redirect_url | ||
|
||
# hit the redirect url (which will issue a cookie and state) | ||
channel = make_request( | ||
self.hs.get_reactor(), | ||
self.site, | ||
"POST", | ||
"/login", | ||
content={"type": "m.login.token", "token": login_token}, | ||
"GET", | ||
"/_matrix/client/r0/login/sso/redirect?" + urllib.parse.urlencode(params), | ||
) | ||
assert channel.code == 200 | ||
return channel.json_body | ||
|
||
assert channel.code == 302 | ||
channel.extract_cookies(cookies) | ||
return channel.headers.getRawHeaders("Location")[0] | ||
|
||
def initiate_sso_ui_auth( | ||
self, ui_auth_session_id: str, cookies: MutableMapping[str, str] | ||
) -> str: | ||
"""Make a request to the ui-auth-via-sso endpoint, and return the target | ||
|
||
Assumes that exactly one SSO provider has been configured. Requires the | ||
AuthRestServlet to be mounted. | ||
|
||
Args: | ||
ui_auth_session_id: the session id of the UI auth | ||
cookies: any cookies returned will be added to this dict | ||
|
||
Returns: | ||
the URI that the client gets linked to (ie, the SSO server) | ||
""" | ||
sso_redirect_endpoint = ( | ||
"/_matrix/client/r0/auth/m.login.sso/fallback/web?" | ||
+ urllib.parse.urlencode({"session": ui_auth_session_id}) | ||
) | ||
# hit the redirect url (which will issue a cookie and state) | ||
channel = make_request( | ||
self.hs.get_reactor(), self.site, "GET", sso_redirect_endpoint | ||
) | ||
# that should serve a confirmation page | ||
assert channel.code == 200, channel.text_body | ||
channel.extract_cookies(cookies) | ||
|
||
# parse the confirmation page to fish out the link. | ||
class ConfirmationPageParser(HTMLParser): | ||
def __init__(self): | ||
super().__init__() | ||
|
||
self.links = [] # type: List[str] | ||
|
||
def handle_starttag( | ||
self, tag: str, attrs: Iterable[Tuple[str, Optional[str]]] | ||
) -> None: | ||
attr_dict = dict(attrs) | ||
if tag == "a": | ||
href = attr_dict["href"] | ||
if href: | ||
self.links.append(href) | ||
|
||
def error(_, message): | ||
raise AssertionError(message) | ||
|
||
p = ConfirmationPageParser() | ||
p.feed(channel.result["body"].decode("utf-8")) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you can use the next There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yup, true. |
||
p.close() | ||
assert len(p.links) == 1, "not exactly one link in confirmation page" | ||
oauth_uri = p.links[0] | ||
return oauth_uri | ||
|
||
|
||
# an 'oidc_config' suitable for login_via_oidc. | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems to return a
FakeChannel
, not just the status code.