Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Allow multiple values for SSO attribute_requirements via comma separation #17949

Open
wants to merge 3 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/17949.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add functionality to be able to use multiple values in SSO feature `attribute_requirements`.
9 changes: 6 additions & 3 deletions docs/usage/configuration/config_documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3298,8 +3298,9 @@ This setting has the following sub-options:
The default is 'uid'.
* `attribute_requirements`: It is possible to configure Synapse to only allow logins if SAML attributes
match particular values. The requirements can be listed under
`attribute_requirements` as shown in the example. All of the listed attributes must
match for the login to be permitted.
`attribute_requirements` as shown in the example. All of the listed attributes must
match for the login to be permitted. Values can be specified in a `one_of` list to allow
multiple values for an attribute.
* `idp_entityid`: If the metadata XML contains multiple IdP entities then the `idp_entityid`
option must be set to the entity to redirect users to.
Most deployments only have a single IdP entity and so should omit this option.
Expand Down Expand Up @@ -3380,7 +3381,9 @@ saml2_config:
- attribute: userGroup
value: "staff"
- attribute: department
value: "sales"
one_of:
- "sales"
- "admins"

idp_entityid: 'https://our_idp/entityid'
```
Expand Down
20 changes: 15 additions & 5 deletions synapse/config/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
#
#
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional

import attr

Expand All @@ -43,13 +43,23 @@ class SsoAttributeRequirement:
"""Object describing a single requirement for SSO attributes."""

attribute: str
# If a value is not given, than the attribute must simply exist.
value: Optional[str]
# If neither value nor one_of is given, the attribute must simply exist. This is
# only true for CAS configs which use a different JSON schema than the one below.
value: Optional[str] = None
one_of: Optional[List[str]] = None

JSON_SCHEMA = {
"type": "object",
"properties": {"attribute": {"type": "string"}, "value": {"type": "string"}},
"required": ["attribute", "value"],
"properties": {
"attribute": {"type": "string"},
"value": {"type": "string"},
"one_of": {"type": "array", "items": {"type": "string"}},
},
"required": ["attribute"],
"oneOf": [
{"required": ["value"]},
{"required": ["one_of"]},
],
}


Expand Down
6 changes: 5 additions & 1 deletion synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -1276,12 +1276,16 @@ def _check_attribute_requirement(
return False

# If the requirement is None, the attribute existing is enough.
if req.value is None:
if req.value is None and req.one_of is None:
return True

values = attributes[req.attribute]
if req.value in values:
return True
if req.one_of:
for value in req.one_of:
if value in values:
return True

logger.info(
"SSO attribute %s did not match required value '%s' (was '%s')",
Expand Down
32 changes: 32 additions & 0 deletions tests/handlers/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,38 @@ def test_attribute_requirements_contains(self) -> None:
auth_provider_session_id=None,
)

@override_config(
{
"oidc_config": {
**DEFAULT_CONFIG,
"attribute_requirements": [
{"attribute": "test", "one_of": ["foo", "bar"]}
],
}
}
)
def test_attribute_requirements_one_of(self) -> None:
"""Test that auth succeeds if userinfo attribute has multiple values and CONTAINS required value"""
# userinfo with "test": ["bar"] attribute should succeed.
userinfo = {
"sub": "tester",
"username": "tester",
"test": ["bar"],
}
request, _ = self.start_authorization(userinfo)
self.get_success(self.handler.handle_oidc_callback(request))

# check that the auth handler got called as expected
self.complete_sso_login.assert_called_once_with(
"@tester:test",
self.provider.idp_id,
request,
ANY,
None,
new_user=True,
auth_provider_session_id=None,
)

@override_config(
{
"oidc_config": {
Expand Down
46 changes: 46 additions & 0 deletions tests/handlers/test_saml.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,52 @@ def test_attribute_requirements(self) -> None:
auth_provider_session_id=None,
)

@override_config(
{
"saml2_config": {
"attribute_requirements": [
{"attribute": "userGroup", "one_of": ["staff", "admin"]},
],
},
}
)
def test_attribute_requirements_one_of(self) -> None:
"""The required attributes can be comma-separated."""

# stub out the auth handler
auth_handler = self.hs.get_auth_handler()
auth_handler.complete_sso_login = AsyncMock() # type: ignore[method-assign]

# The response doesn't have the proper department.
saml_response = FakeAuthnResponse(
{"uid": "test_user", "username": "test_user", "userGroup": ["nogroup"]}
)
request = _mock_request()
self.get_success(
self.handler._handle_authn_response(request, saml_response, "redirect_uri")
)
auth_handler.complete_sso_login.assert_not_called()

# Add the proper attributes and it should succeed.
saml_response = FakeAuthnResponse(
{"uid": "test_user", "username": "test_user", "userGroup": ["admin"]}
)
request.reset_mock()
self.get_success(
self.handler._handle_authn_response(request, saml_response, "redirect_uri")
)

# check that the auth handler got called as expected
auth_handler.complete_sso_login.assert_called_once_with(
"@test_user:test",
"saml",
request,
"redirect_uri",
None,
new_user=True,
auth_provider_session_id=None,
)


def _mock_request() -> Mock:
"""Returns a mock which will stand in as a SynapseRequest"""
Expand Down