Support MSC4450: Identity Provider selection for User-Interactive Authentication with Legacy Single Sign-On. (#19693)

Closes: #19688

Part of: MSC4450 whose Experimental Feature tracking issue is #19691


Add an unstable, namespaced `idp_id` query parameter to `fallback/web` \
This allows clients to specify the identity provider they'd like to log
in with for SSO when they have multiple upstream IdPs associated with
their account.

Previously, Synapse would just pick one arbitrarily. But this was
undesirable as you may want to use a different one at that point in
time. When logging in, the user is able to choose when IdP they use -
during UIA (which uses fallback auth mechanism) they should be able to
do the same.

-----

Signed-off-by: Olivier 'reivilibre <oliverw@matrix.org>
Co-authored-by: Andrew Morgan <andrew@amorgan.xyz>
Co-authored-by: Eric Eastwood <madlittlemods@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-04-16 12:19:28 +01:00
1 parent e1b56313de
commit 964ca65ebb
5 files changed
+190 -13

No files matched your search

+140
View File
@@ -43,6 +43,62 @@ from tests.rest.client.utils import TEST_OIDC_CONFIG, TEST_OIDC_ISSUER
from tests.server import FakeChannel
from tests.unittest import override_config, skip_unless
TEST_MULTIPLE_OIDC_IDP_ID1 = "friendface"
TEST_MULTIPLE_OIDC_IDP_ID2 = "potatocloud"
TEST_MULTIPLE_OIDC_IDP_ID3 = "svnswitch"
TEST_MULTIPLE_OIDC_ISSUER1 = "https://friendface.invalid/"
TEST_MULTIPLE_OIDC_ISSUER2 = "https://potato.invalid/"
TEST_MULTIPLE_OIDC_ISSUER3 = "https://subversionswitch.invalid/"
TEST_MULTIPLE_OIDC_PROVIDERS = [
{
"idp_id": TEST_MULTIPLE_OIDC_IDP_ID1,
"idp_name": "FriendFace",
"idp_icon": "mxc://example.invalid/friendface",
"idp_brand": "friendface",
"issuer": TEST_MULTIPLE_OIDC_ISSUER1,
"client_id": "id-for-friendface",
"client_secret": "secret-for-friendface",
"scopes": ["openid"],
"discover": False,
"authorization_endpoint": f"{TEST_MULTIPLE_OIDC_ISSUER1}oidc/auth",
"token_endpoint": f"{TEST_MULTIPLE_OIDC_ISSUER1}oidc/token",
"jwks_uri": f"{TEST_MULTIPLE_OIDC_ISSUER1}oidc/jwks",
},
{
"idp_id": TEST_MULTIPLE_OIDC_IDP_ID2,
"idp_name": "Potato Cloud",
"idp_icon": "mxc://example.invalid/potatocloud",
"idp_brand": "potato",
"issuer": TEST_MULTIPLE_OIDC_ISSUER2,
"client_id": "id-for-potato",
"client_secret": "secret-for-potato",
"scopes": ["openid"],
"discover": False,
"authorization_endpoint": f"{TEST_MULTIPLE_OIDC_ISSUER2}oidc/auth",
"token_endpoint": f"{TEST_MULTIPLE_OIDC_ISSUER2}oidc/token",
"jwks_uri": f"{TEST_MULTIPLE_OIDC_ISSUER2}oidc/jwks",
},
{
"idp_id": TEST_MULTIPLE_OIDC_IDP_ID3,
"idp_name": "SubversionSwitch",
"idp_icon": "mxc://example.invalid/svnswitch",
"idp_brand": "svnswitch",
"issuer": TEST_MULTIPLE_OIDC_ISSUER3,
"client_id": "id-for-svnswitch",
"client_secret": "secret-for-svnswitch",
"scopes": ["openid"],
"discover": False,
"authorization_endpoint": f"{TEST_MULTIPLE_OIDC_ISSUER3}oidc/auth",
"token_endpoint": f"{TEST_MULTIPLE_OIDC_ISSUER3}oidc/token",
"jwks_uri": f"{TEST_MULTIPLE_OIDC_ISSUER3}oidc/jwks",
},
]
"""
`oidc_providers` config example for multiple OIDC providers.
"""
class DummyRecaptchaChecker(UserInteractiveAuthChecker):
def __init__(self, hs: HomeServer) -> None:
@@ -506,6 +562,90 @@ class UIAuthTests(unittest.HomeserverTestCase):
body={"auth": {"session": session_id}},
)
@skip_unless(HAS_OIDC, "requires OIDC")
@override_config(
{
"oidc_providers": TEST_MULTIPLE_OIDC_PROVIDERS,
"experimental_features": {"msc4450_enabled": True},
}
)
def test_msc4450_select_idp_id(self) -> None:
"""
Test for MSC4450: Identity Provider selection for
User-Interactive Authentication with Legacy Single Sign-On.
We configure 3 OIDC providers and then check that we can select
which one to redirect to for User-Interactive Authentication.
"""
# Attach the user to the OIDC providers manually
for idp_id in (
TEST_MULTIPLE_OIDC_IDP_ID1,
TEST_MULTIPLE_OIDC_IDP_ID2,
):
self.get_success(
self.hs.get_datastores().main.record_user_external_id(
# `oidc-` is a magic prefix needed for OIDC providers
f"oidc-{idp_id}",
# arbitrary/opaque provider-specific external ID, doesn't really matter
"some.ext.user.id",
self.user,
)
)
# Start a user-interactive authentication session by
# calling the device deletion API
channel = self.delete_device(
self.user_tok, self.device_id, HTTPStatus.UNAUTHORIZED
)
auth_session = channel.json_body["session"]
flows = channel.json_body["flows"]
self.assertCountEqual(
flows, [{"stages": ["m.login.sso"]}, {"stages": ["m.login.password"]}]
)
# Try to start the User-Interactive Auth against both identity providers.
# Make sure we get the identity provider that we ask for.
#
# We need to try against both to be sure that Synapse doesn't just conveniently happen to
# arbitrarily select the identity provider we test.
for idp_id, provider_config in (
(
TEST_MULTIPLE_OIDC_IDP_ID1,
TEST_MULTIPLE_OIDC_PROVIDERS[0],
),
(
TEST_MULTIPLE_OIDC_IDP_ID2,
TEST_MULTIPLE_OIDC_PROVIDERS[1],
),
):
endpoint = f"/_matrix/client/v3/auth/m.login.sso/fallback/web?session={auth_session}&io.element.idp_id=oidc-{idp_id}"
channel = self.make_request("GET", endpoint)
self.assertEqual(
channel.code,
HTTPStatus.OK,
f"Failed to use the {endpoint} endpoint as part of the UIA flow for idp_id={idp_id} : response_body={channel.text_body}",
)
# This 'Continue with ...' text is templated by `synapse/res/templates/sso_auth_confirm.html`
self.assertIn(
f"Continue with {provider_config['idp_name']}",
channel.text_body,
)
self.assertIn(provider_config["authorization_endpoint"], channel.text_body)
# Test that we can't use the 3rd OIDC provider as we're not
# registered with it
channel = self.make_request(
"GET",
f"/_matrix/client/v3/auth/m.login.sso/fallback/web?session={auth_session}&io.element.idp_id=oidc-{TEST_MULTIPLE_OIDC_IDP_ID3}",
)
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST, channel.json_body)
self.assertEqual(
channel.json_body["errcode"], Codes.INVALID_PARAM, channel.json_body
)
@skip_unless(HAS_OIDC, "requires OIDC")
@override_config({"oidc_config": TEST_OIDC_CONFIG})
def test_does_not_offer_password_for_sso_user(self) -> None: