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

Commit dd5e5dc

Browse files
authored
Add SSO attribute requirements for OIDC providers (#9609)
Allows limiting who can login using OIDC via the claims made from the IdP.
1 parent 8000cf1 commit dd5e5dc

File tree

5 files changed

+209
-1
lines changed

5 files changed

+209
-1
lines changed

changelog.d/9609.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Logins using OpenID Connect can require attributes on the `userinfo` response in order to login. Contributed by Hubbe King.

docs/sample_config.yaml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1873,6 +1873,24 @@ saml2_config:
18731873
# which is set to the claims returned by the UserInfo Endpoint and/or
18741874
# in the ID Token.
18751875
#
1876+
# It is possible to configure Synapse to only allow logins if certain attributes
1877+
# match particular values in the OIDC userinfo. The requirements can be listed under
1878+
# `attribute_requirements` as shown below. All of the listed attributes must
1879+
# match for the login to be permitted. Additional attributes can be added to
1880+
# userinfo by expanding the `scopes` section of the OIDC config to retrieve
1881+
# additional information from the OIDC provider.
1882+
#
1883+
# If the OIDC claim is a list, then the attribute must match any value in the list.
1884+
# Otherwise, it must exactly match the value of the claim. Using the example
1885+
# below, the `family_name` claim MUST be "Stephensson", but the `groups`
1886+
# claim MUST contain "admin".
1887+
#
1888+
# attribute_requirements:
1889+
# - attribute: family_name
1890+
# value: "Stephensson"
1891+
# - attribute: groups
1892+
# value: "admin"
1893+
#
18761894
# See https://github.com/matrix-org/synapse/blob/master/docs/openid.md
18771895
# for information on how to configure these options.
18781896
#
@@ -1905,6 +1923,9 @@ oidc_providers:
19051923
# localpart_template: "{{ user.login }}"
19061924
# display_name_template: "{{ user.name }}"
19071925
# email_template: "{{ user.email }}"
1926+
# attribute_requirements:
1927+
# - attribute: userGroup
1928+
# value: "synapseUsers"
19081929

19091930
# For use with Keycloak
19101931
#
@@ -1914,6 +1935,9 @@ oidc_providers:
19141935
# client_id: "synapse"
19151936
# client_secret: "copy secret generated in Keycloak UI"
19161937
# scopes: ["openid", "profile"]
1938+
# attribute_requirements:
1939+
# - attribute: groups
1940+
# value: "admin"
19171941

19181942
# For use with Github
19191943
#

synapse/config/oidc_config.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
# limitations under the License.
1616

1717
from collections import Counter
18-
from typing import Iterable, Mapping, Optional, Tuple, Type
18+
from typing import Iterable, List, Mapping, Optional, Tuple, Type
1919

2020
import attr
2121

2222
from synapse.config._util import validate_config
23+
from synapse.config.sso import SsoAttributeRequirement
2324
from synapse.python_dependencies import DependencyException, check_requirements
2425
from synapse.types import Collection, JsonDict
2526
from synapse.util.module_loader import load_module
@@ -191,6 +192,24 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
191192
# which is set to the claims returned by the UserInfo Endpoint and/or
192193
# in the ID Token.
193194
#
195+
# It is possible to configure Synapse to only allow logins if certain attributes
196+
# match particular values in the OIDC userinfo. The requirements can be listed under
197+
# `attribute_requirements` as shown below. All of the listed attributes must
198+
# match for the login to be permitted. Additional attributes can be added to
199+
# userinfo by expanding the `scopes` section of the OIDC config to retrieve
200+
# additional information from the OIDC provider.
201+
#
202+
# If the OIDC claim is a list, then the attribute must match any value in the list.
203+
# Otherwise, it must exactly match the value of the claim. Using the example
204+
# below, the `family_name` claim MUST be "Stephensson", but the `groups`
205+
# claim MUST contain "admin".
206+
#
207+
# attribute_requirements:
208+
# - attribute: family_name
209+
# value: "Stephensson"
210+
# - attribute: groups
211+
# value: "admin"
212+
#
194213
# See https://github.com/matrix-org/synapse/blob/master/docs/openid.md
195214
# for information on how to configure these options.
196215
#
@@ -223,6 +242,9 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
223242
# localpart_template: "{{{{ user.login }}}}"
224243
# display_name_template: "{{{{ user.name }}}}"
225244
# email_template: "{{{{ user.email }}}}"
245+
# attribute_requirements:
246+
# - attribute: userGroup
247+
# value: "synapseUsers"
226248
227249
# For use with Keycloak
228250
#
@@ -232,6 +254,9 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
232254
# client_id: "synapse"
233255
# client_secret: "copy secret generated in Keycloak UI"
234256
# scopes: ["openid", "profile"]
257+
# attribute_requirements:
258+
# - attribute: groups
259+
# value: "admin"
235260
236261
# For use with Github
237262
#
@@ -329,6 +354,10 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
329354
},
330355
"allow_existing_users": {"type": "boolean"},
331356
"user_mapping_provider": {"type": ["object", "null"]},
357+
"attribute_requirements": {
358+
"type": "array",
359+
"items": SsoAttributeRequirement.JSON_SCHEMA,
360+
},
332361
},
333362
}
334363

@@ -465,6 +494,11 @@ def _parse_oidc_config_dict(
465494
jwt_header=client_secret_jwt_key_config["jwt_header"],
466495
jwt_payload=client_secret_jwt_key_config.get("jwt_payload", {}),
467496
)
497+
# parse attribute_requirements from config (list of dicts) into a list of SsoAttributeRequirement
498+
attribute_requirements = [
499+
SsoAttributeRequirement(**x)
500+
for x in oidc_config.get("attribute_requirements", [])
501+
]
468502

469503
return OidcProviderConfig(
470504
idp_id=idp_id,
@@ -488,6 +522,7 @@ def _parse_oidc_config_dict(
488522
allow_existing_users=oidc_config.get("allow_existing_users", False),
489523
user_mapping_provider_class=user_mapping_provider_class,
490524
user_mapping_provider_config=user_mapping_provider_config,
525+
attribute_requirements=attribute_requirements,
491526
)
492527

493528

@@ -577,3 +612,6 @@ class OidcProviderConfig:
577612

578613
# the config of the user mapping provider
579614
user_mapping_provider_config = attr.ib()
615+
616+
# required attributes to require in userinfo to allow login/registration
617+
attribute_requirements = attr.ib(type=List[SsoAttributeRequirement])

synapse/handlers/oidc_handler.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,7 @@ def __init__(
280280
self._config = provider
281281
self._callback_url = hs.config.oidc_callback_url # type: str
282282

283+
self._oidc_attribute_requirements = provider.attribute_requirements
283284
self._scopes = provider.scopes
284285
self._user_profile_method = provider.user_profile_method
285286

@@ -859,6 +860,18 @@ async def handle_oidc_callback(
859860
)
860861

861862
# otherwise, it's a login
863+
logger.debug("Userinfo for OIDC login: %s", userinfo)
864+
865+
# Ensure that the attributes of the logged in user meet the required
866+
# attributes by checking the userinfo against attribute_requirements
867+
# In order to deal with the fact that OIDC userinfo can contain many
868+
# types of data, we wrap non-list values in lists.
869+
if not self._sso_handler.check_required_attributes(
870+
request,
871+
{k: v if isinstance(v, list) else [v] for k, v in userinfo.items()},
872+
self._oidc_attribute_requirements,
873+
):
874+
return
862875

863876
# Call the mapper to register/login the user
864877
try:

tests/handlers/test_oidc.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -989,6 +989,138 @@ def test_null_localpart(self):
989989
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
990990
self.assertRenderedError("mapping_error", "localpart is invalid: ")
991991

992+
@override_config(
993+
{
994+
"oidc_config": {
995+
**DEFAULT_CONFIG,
996+
"attribute_requirements": [{"attribute": "test", "value": "foobar"}],
997+
}
998+
}
999+
)
1000+
def test_attribute_requirements(self):
1001+
"""The required attributes must be met from the OIDC userinfo response."""
1002+
auth_handler = self.hs.get_auth_handler()
1003+
auth_handler.complete_sso_login = simple_async_mock()
1004+
1005+
# userinfo lacking "test": "foobar" attribute should fail.
1006+
userinfo = {
1007+
"sub": "tester",
1008+
"username": "tester",
1009+
}
1010+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1011+
auth_handler.complete_sso_login.assert_not_called()
1012+
1013+
# userinfo with "test": "foobar" attribute should succeed.
1014+
userinfo = {
1015+
"sub": "tester",
1016+
"username": "tester",
1017+
"test": "foobar",
1018+
}
1019+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1020+
1021+
# check that the auth handler got called as expected
1022+
auth_handler.complete_sso_login.assert_called_once_with(
1023+
"@tester:test", "oidc", ANY, ANY, None, new_user=True
1024+
)
1025+
1026+
@override_config(
1027+
{
1028+
"oidc_config": {
1029+
**DEFAULT_CONFIG,
1030+
"attribute_requirements": [{"attribute": "test", "value": "foobar"}],
1031+
}
1032+
}
1033+
)
1034+
def test_attribute_requirements_contains(self):
1035+
"""Test that auth succeeds if userinfo attribute CONTAINS required value"""
1036+
auth_handler = self.hs.get_auth_handler()
1037+
auth_handler.complete_sso_login = simple_async_mock()
1038+
# userinfo with "test": ["foobar", "foo", "bar"] attribute should succeed.
1039+
userinfo = {
1040+
"sub": "tester",
1041+
"username": "tester",
1042+
"test": ["foobar", "foo", "bar"],
1043+
}
1044+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1045+
1046+
# check that the auth handler got called as expected
1047+
auth_handler.complete_sso_login.assert_called_once_with(
1048+
"@tester:test", "oidc", ANY, ANY, None, new_user=True
1049+
)
1050+
1051+
@override_config(
1052+
{
1053+
"oidc_config": {
1054+
**DEFAULT_CONFIG,
1055+
"attribute_requirements": [{"attribute": "test", "value": "foobar"}],
1056+
}
1057+
}
1058+
)
1059+
def test_attribute_requirements_mismatch(self):
1060+
"""
1061+
Test that auth fails if attributes exist but don't match,
1062+
or are non-string values.
1063+
"""
1064+
auth_handler = self.hs.get_auth_handler()
1065+
auth_handler.complete_sso_login = simple_async_mock()
1066+
# userinfo with "test": "not_foobar" attribute should fail
1067+
userinfo = {
1068+
"sub": "tester",
1069+
"username": "tester",
1070+
"test": "not_foobar",
1071+
}
1072+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1073+
auth_handler.complete_sso_login.assert_not_called()
1074+
1075+
# userinfo with "test": ["foo", "bar"] attribute should fail
1076+
userinfo = {
1077+
"sub": "tester",
1078+
"username": "tester",
1079+
"test": ["foo", "bar"],
1080+
}
1081+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1082+
auth_handler.complete_sso_login.assert_not_called()
1083+
1084+
# userinfo with "test": False attribute should fail
1085+
# this is largely just to ensure we don't crash here
1086+
userinfo = {
1087+
"sub": "tester",
1088+
"username": "tester",
1089+
"test": False,
1090+
}
1091+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1092+
auth_handler.complete_sso_login.assert_not_called()
1093+
1094+
# userinfo with "test": None attribute should fail
1095+
# a value of None breaks the OIDC spec, but it's important to not crash here
1096+
userinfo = {
1097+
"sub": "tester",
1098+
"username": "tester",
1099+
"test": None,
1100+
}
1101+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1102+
auth_handler.complete_sso_login.assert_not_called()
1103+
1104+
# userinfo with "test": 1 attribute should fail
1105+
# this is largely just to ensure we don't crash here
1106+
userinfo = {
1107+
"sub": "tester",
1108+
"username": "tester",
1109+
"test": 1,
1110+
}
1111+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1112+
auth_handler.complete_sso_login.assert_not_called()
1113+
1114+
# userinfo with "test": 3.14 attribute should fail
1115+
# this is largely just to ensure we don't crash here
1116+
userinfo = {
1117+
"sub": "tester",
1118+
"username": "tester",
1119+
"test": 3.14,
1120+
}
1121+
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
1122+
auth_handler.complete_sso_login.assert_not_called()
1123+
9921124
def _generate_oidc_session_token(
9931125
self,
9941126
state: str,

0 commit comments

Comments
 (0)