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

Allow multiple values for OIDC attribute requirements #12488

Closed
Closed
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/12488.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add the ability of filtering multiple SSO attribute values.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, please expand on this. It won't make any sense to most readers.

My understanding is that it only applies to OIDC and not other SSO mechanisms such as SAML?

17 changes: 11 additions & 6 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1996,17 +1996,22 @@ saml2_config:
#
# It is possible to configure Synapse to only allow logins if certain attributes
# match particular values in the OIDC userinfo. The requirements can be listed under
# `attribute_requirements` as shown below. All of the listed attributes must
# match for the login to be permitted. Additional attributes can be added to
# userinfo by expanding the `scopes` section of the OIDC config to retrieve
# additional information from the OIDC provider.
# `attribute_requirements` as shown below. All of the listed in `value` attributes and
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by the way, don't forget to update the manual at https://github.com/matrix-org/synapse/blob/develop/docs/usage/configuration/config_documentation.md - currently this has to be done manually. Maybe best to wait until we can agree wording though.

# any of the listed in `values` attributes must match for the login to be permitted.
# Additional attributes can be added to userinfo by expanding the `scopes` section
# of the OIDC config to retrieve additional information from the OIDC provider.
#
# If you specify both `value` and `values` entries, then only `value` entry will be checked
# and `values` will be ignored.
#
# If the OIDC claim is a list, then the attribute must match any value in the list.
# Otherwise, it must exactly match the value of the claim. Using the example
# below, the `family_name` claim MUST be "Stephensson", but the `groups`
# claim MUST contain "admin".
# below, the `given_name` claim MUST be either "Andries" or "Michael", the `family_name`
# claim MUST be "Stephensson", but the `groups` claim MUST contain "admin".
#
# attribute_requirements:
# - attribute: given_name
# values: ["Andries", "Michael"]
# - attribute: family_name
# value: "Stephensson"
# - attribute: groups
Expand Down
17 changes: 11 additions & 6 deletions synapse/config/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,17 +208,22 @@ def generate_config_section(self, **kwargs: Any) -> str:
#
# It is possible to configure Synapse to only allow logins if certain attributes
# match particular values in the OIDC userinfo. The requirements can be listed under
# `attribute_requirements` as shown below. All of the listed attributes must
# match for the login to be permitted. Additional attributes can be added to
# userinfo by expanding the `scopes` section of the OIDC config to retrieve
# additional information from the OIDC provider.
# `attribute_requirements` as shown below. All of the listed in `value` attributes and
# any of the listed in `values` attributes must match for the login to be permitted.
Comment on lines +211 to +212
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new sentence isn't very clear - please could you expand on it somewhat?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general this paragraph is very unclear to start with, so anything you can do to improve it would be much appreciated.

# Additional attributes can be added to userinfo by expanding the `scopes` section
# of the OIDC config to retrieve additional information from the OIDC provider.
#
# If you specify both `value` and `values` entries, then only `value` entry will be checked
# and `values` will be ignored.
Comment on lines +216 to +217
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# If you specify both `value` and `values` entries, then only `value` entry will be checked
# and `values` will be ignored.
# If you specify both `value` and `values` entries, then only the `value` entry will be checked
# and `values` will be ignored.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you're using oneOf in the json schema, my understanding is that if you specify both values, the configuration will be rejected. Are you sure this sentence is correct?

#
# If the OIDC claim is a list, then the attribute must match any value in the list.
# Otherwise, it must exactly match the value of the claim. Using the example
# below, the `family_name` claim MUST be "Stephensson", but the `groups`
# claim MUST contain "admin".
# below, the `given_name` claim MUST be either "Andries" or "Michael", the `family_name`
# claim MUST be "Stephensson", but the `groups` claim MUST contain "admin".
#
# attribute_requirements:
# - attribute: given_name
# values: ["Andries", "Michael"]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think a different name might make the behaviour here clearer - for example allowable_values ?

# - attribute: family_name
# value: "Stephensson"
# - attribute: groups
Expand Down
24 changes: 19 additions & 5 deletions synapse/config/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional

import attr

Expand All @@ -36,13 +36,27 @@ 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 a value or values are not given, than the attribute must simply exist.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

according to the schema, one or both of value or values is required - so I don't understand this comment?

value: Optional[str] = None
values: Optional[List[str]] = []
Comment on lines +40 to +41
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than having both of these, could we just have a values: List[str], and convert value into a single-item list?


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


Expand Down
11 changes: 8 additions & 3 deletions synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -1042,12 +1042,17 @@ def _check_attribute_requirement(
logger.info("SSO attribute missing: %s", req.attribute)
return False

# If the requirement is None, the attribute existing is enough.
if req.value is None:
req_values = []
if req.value is not None:
req_values.append(req.value)
elif req.values:
req_values.extend(req.values)
else:
# If the requirement is None or empty, the attribute existing is enough.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

having special behaviour for an empty list feels extremely unintuitive. At the very least it should be documented, but I am not in favour.

return True

values = attributes[req.attribute]
if req.value in values:
if any(req_value in values for req_value in req_values):
return True

logger.info(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please update the log line here - it will be incorrect if values was specified.

Expand Down
148 changes: 148 additions & 0 deletions tests/handlers/test_oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -1114,6 +1114,46 @@ def test_attribute_requirements(self) -> None:
auth_provider_session_id=None,
)

@override_config(
{
"oidc_config": {
**DEFAULT_CONFIG,
"attribute_requirements": [{"attribute": "test", "values": []}],
}
}
)
def test_attribute_requirements_exists(self) -> None:
"""The required attributes must exists."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""The required attributes must exists."""
"""The required attributes must exist."""

auth_handler = self.hs.get_auth_handler()
auth_handler.complete_sso_login = simple_async_mock()

# userinfo lacking "test": "foobar" attribute should fail.
userinfo = {
"sub": "tester",
"username": "tester",
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()

# userinfo with "test": "foobar" attribute should succeed.
userinfo = {
"sub": "tester",
"username": "tester",
"test": "foobar",
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))

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

@override_config(
{
"oidc_config": {
Expand Down Expand Up @@ -1145,6 +1185,39 @@ def test_attribute_requirements_contains(self) -> None:
auth_provider_session_id=None,
)

@override_config(
{
"oidc_config": {
**DEFAULT_CONFIG,
"attribute_requirements": [
{"attribute": "test", "values": ["not_foobar", "bar"]}
],
}
}
)
def test_attribute_requirements_contains_any_values(self) -> None:
"""Test that auth succeeds if userinfo attribute CONTAINS ANY required values"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"""Test that auth succeeds if userinfo attribute CONTAINS ANY required values"""
"""Test that auth succeeds if userinfo attribute contains any of the required values"""

auth_handler = self.hs.get_auth_handler()
auth_handler.complete_sso_login = simple_async_mock()
# userinfo with "test": ["foobar", "foo", "bar"] attribute should succeed.
userinfo = {
"sub": "tester",
"username": "tester",
"test": ["foobar", "foo", "bar"],
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))

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

@override_config(
{
"oidc_config": {
Expand Down Expand Up @@ -1237,6 +1310,81 @@ def _generate_oidc_session_token(
),
)

@override_config(
{
"oidc_config": {
**DEFAULT_CONFIG,
"attribute_requirements": [
{"attribute": "test", "values": ["foobar", "barfoo"]}
],
}
}
)
def test_attribute_requirements_mismatch_any_values(self) -> None:
"""
Test that auth fails if attributes exist but don't match ANY VALUE,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Test that auth fails if attributes exist but don't match ANY VALUE,
Test that auth fails if attributes exist but don't match any of the required values,

or are non-string values.
"""
auth_handler = self.hs.get_auth_handler()
auth_handler.complete_sso_login = simple_async_mock()
# userinfo with "test": "not_foobar" attribute should fail
userinfo: dict = {
"sub": "tester",
"username": "tester",
"test": "not_foobar",
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()

# userinfo with "test": ["foo", "bar"] attribute should fail
userinfo = {
"sub": "tester",
"username": "tester",
"test": ["foo", "bar"],
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()

# userinfo with "test": False attribute should fail
# this is largely just to ensure we don't crash here
userinfo = {
"sub": "tester",
"username": "tester",
"test": False,
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()

# userinfo with "test": None attribute should fail
# a value of None breaks the OIDC spec, but it's important to not crash here
userinfo = {
"sub": "tester",
"username": "tester",
"test": None,
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()

# userinfo with "test": 1 attribute should fail
# this is largely just to ensure we don't crash here
userinfo = {
"sub": "tester",
"username": "tester",
"test": 1,
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()

# userinfo with "test": 3.14 attribute should fail
# this is largely just to ensure we don't crash here
userinfo = {
"sub": "tester",
"username": "tester",
"test": 3.14,
}
self.get_success(_make_callback_with_userinfo(self.hs, userinfo))
auth_handler.complete_sso_login.assert_not_called()


async def _make_callback_with_userinfo(
hs: HomeServer, userinfo: dict, client_redirect_url: str = "http://client/redirect"
Expand Down