Skip to content
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
186 changes: 137 additions & 49 deletions README.md

Large diffs are not rendered by default.

26 changes: 23 additions & 3 deletions synapse_token_authenticator/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ def __init__(self, other: dict):
if config := other.get("oauth"):

Path: TypeAlias = Union[str, List[str]]
PathList: TypeAlias = Union[Path, List[List[str]]]

@dataclass
class JwtValidationConfig:
Expand All @@ -68,7 +67,7 @@ class JwtValidationConfig:
user_id_path: Path | None = None
fq_uid_path: Path | None = None
displayname_path: Path | None = None
admin_path: PathList | None = None
admin_validator: Validator | None = None
email_path: Path | None = None
required_scopes: str | List[str] | None = None
jwk_set: JWKSet | JWK | None = None
Expand All @@ -79,6 +78,9 @@ def __post_init__(self):
if not isinstance(self.validator, Exist):
self.validator = parse_validator(self.validator)

if self.admin_validator is not None:
self.admin_validator = parse_validator(self.admin_validator)

if self.jwk_set and ("keys" in self.jwk_set):
self.jwk_set = JWKSet(**self.jwk_set)
elif self.jwk_set:
Expand All @@ -98,14 +100,17 @@ class IntrospectionValidationConfig:
user_id_path: Path | None = None
fq_uid_path: Path | None = None
displayname_path: Path | None = None
admin_path: PathList | None = None
admin_validator: Validator | None = None
email_path: Path | None = None
required_scopes: str | List[str] | None = None

def __post_init__(self):
if not isinstance(self.validator, Exist):
self.validator = parse_validator(self.validator)

if self.admin_validator is not None:
self.admin_validator = parse_validator(self.admin_validator)

if not isinstance(self.auth, NoAuth):
self.auth = parse_auth(self.auth)

Expand All @@ -119,6 +124,11 @@ def __post_init__(self):
if not isinstance(self.auth, NoAuth):
self.auth = parse_auth(self.auth)

@dataclass
class OAuthSysadminConfig:
external_id: str
issuer: str

@dataclass
class OAuthConfig:
jwt_validation: JwtValidationConfig | None = None
Expand All @@ -128,8 +138,18 @@ class OAuthConfig:
expose_metadata_resource: Any = None
registration_enabled: bool = False
check_external_id: bool = True
sysadmins: List[OAuthSysadminConfig] | None = None

def __post_init__(self):
if self.sysadmins:
self.sysadmins = [
(
OAuthSysadminConfig(**entry)
if isinstance(entry, dict)
else entry
)
for entry in self.sysadmins
]
if self.notify_on_registration:
self.notify_on_registration = NotifyOnRegistration(
**self.notify_on_registration
Expand Down
29 changes: 21 additions & 8 deletions synapse_token_authenticator/token_authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,18 +497,22 @@ def get_from_set(set_):
return None

try:
get_admin_mb = if_not_none(lambda x: x.admin_path)
get_admin_mb = if_not_none(lambda x: x.admin_validator)

def admin_result(claims: dict, validation_cfg) -> bool | None:
v = get_admin_mb(validation_cfg)
return v.validate(claims) if v is not None else None

admin = all_list_elems_are_equal_return_the_elem(
[
get_from_set(jwt_claims)(get_admin_mb(config.jwt_validation)),
get_from_set(introspection_claims)(
get_admin_mb(config.introspection_validation)
),
admin_result(jwt_claims, config.jwt_validation),
admin_result(introspection_claims, config.introspection_validation),
]
)
except Exception as e:
logger.info(e)
return None
admin = bool(admin)

try:
get_email_mb = if_not_none(lambda x: x.email_path)
Expand Down Expand Up @@ -554,7 +558,15 @@ def get_from_set(set_):

user_exists = await self.api.check_user_exists(fully_qualified_uid)

if not user_exists and not config.registration_enabled:
oauth_sysadmin_match = config.sysadmins is not None and any(
str(s.external_id) == str(external_id)
and str(s.issuer) == str(auth_provider)
for s in config.sysadmins
)

registration_allowed = config.registration_enabled or oauth_sysadmin_match

if not user_exists and not registration_allowed:
logger.info("User doesn't exist and registration is disabled")
return None

Expand All @@ -578,9 +590,10 @@ def get_from_set(set_):
if config.notify_on_registration.interrupt_on_error:
return None

user_id = await self.api.register_user(localpart, admin=bool(admin))
register_as_admin = bool(admin) or oauth_sysadmin_match
user_id = await self.api.register_user(localpart, admin=register_as_admin)
logger.debug(
f"User '{localpart}' created as '{'Admin' if bool(admin) else 'User'}'"
f"User '{localpart}' created as '{'Admin' if register_as_admin else 'User'}'"
)

if email:
Expand Down
2 changes: 1 addition & 1 deletion tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@

from jwcrypto import jwe, jwk, jwt
from synapse.server import HomeServer
from synapse.util.clock import Clock
from tests.synapse_clock import Clock
from twisted.internet.testing import MemoryReactor
from typing_extensions import override

Expand Down
25 changes: 17 additions & 8 deletions tests/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
# ruff: noqa: ARG001 ARG002

import hashlib
import inspect
import ipaddress
import logging
import os
Expand Down Expand Up @@ -45,7 +46,7 @@
from synapse.storage.engines import create_engine
from synapse.storage.prepare_database import prepare_database
from synapse.types import ISynapseReactor
from synapse.util.clock import Clock
from tests.synapse_clock import Clock
from twisted.internet import address, tcp, threads, udp
from twisted.internet.defer import Deferred
from twisted.internet.interfaces import (
Expand Down Expand Up @@ -275,7 +276,10 @@ def runInteraction(

def get_clock() -> tuple[ThreadedMemoryReactorClock, Clock]:
clock = ThreadedMemoryReactorClock()
hs_clock = Clock(clock, "test")
try:
hs_clock = Clock(clock, "test")
except TypeError:
hs_clock = Clock(clock)
return clock, hs_clock


Expand Down Expand Up @@ -332,12 +336,17 @@ def setup_test_homeserver(
global PREPPED_SQLITE_DB_CONN
if PREPPED_SQLITE_DB_CONN is None:
temp_engine = create_engine(database_config)
PREPPED_SQLITE_DB_CONN = LoggingDatabaseConnection(
conn=sqlite3.connect(":memory:"),
engine=temp_engine,
default_txn_name="PREPPED_CONN",
server_name="test",
)
_prep_conn_kw: dict = {
"conn": sqlite3.connect(":memory:"),
"engine": temp_engine,
"default_txn_name": "PREPPED_CONN",
}
if (
"server_name"
in inspect.signature(LoggingDatabaseConnection.__init__).parameters
):
_prep_conn_kw["server_name"] = "test"
PREPPED_SQLITE_DB_CONN = LoggingDatabaseConnection(**_prep_conn_kw)

database = DatabaseConnectionConfig("master", database_config)
config.database.databases = [database]
Expand Down
7 changes: 7 additions & 0 deletions tests/synapse_clock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Synapse moved Clock from synapse.util.clock to synapse.util.
try:
from synapse.util.clock import Clock
except ModuleNotFoundError:
from synapse.util import Clock

__all__ = ["Clock"]
132 changes: 123 additions & 9 deletions tests/test_oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,52 @@ async def test_token_claims_username_mismatch(self):
async def test_valid_login_registration_disabled(self, *args):
token = get_jwt_token("alice", claims=default_claims)
result = await self.hs.mockmod.check_oauth(
"alice", "com.famedly.login.token.epa", {"token": token}
"alice", "com.famedly.login.token.oauth", {"token": token}
)
self.assertEqual(result, None)

config_for_jwt_reg_disabled_sysadmin = deepcopy(config_for_jwt_reg_disabled)
config_for_jwt_reg_disabled_sysadmin["modules"][0]["config"]["oauth"][
"sysadmins"
] = [
{"external_id": "aliceid", "issuer": "http://test.example"},
]

@synapsetest.override_config(config_for_jwt_reg_disabled_sysadmin)
@mock.patch("synapse.module_api.ModuleApi.check_user_exists", return_value=False)
@mock.patch(
"synapse.http.client.SimpleHttpClient.post_json_get_json", return_value={}
)
@mock.patch(
"synapse.module_api.ModuleApi.record_user_external_id",
new_callable=mock.AsyncMock,
)
@mock.patch("synapse.module_api.ModuleApi.register_user")
async def test_sysadmin_registration_allowed_when_jwt_sub_and_iss_match(
self, register_user_mock, *args
):
"""Registration is allowed when ``sysadmins`` matches JWT ``sub`` / ``iss``."""
token = get_jwt_token("aliceid", claims=default_claims)
result = await self.hs.mockmod.check_oauth(
"alice", "com.famedly.login.token.oauth", {"token": token}
)
register_user_mock.assert_called_with("alice", admin=True)
self.assertEqual(result[0], "@alice:example.test")

config_for_jwt_reg_disabled_sysadmin_wrong_sub = deepcopy(
config_for_jwt_reg_disabled_sysadmin
)
config_for_jwt_reg_disabled_sysadmin_wrong_sub["modules"][0]["config"]["oauth"][
"sysadmins"
] = [{"external_id": "other-id", "issuer": "http://test.example"}]

@synapsetest.override_config(config_for_jwt_reg_disabled_sysadmin_wrong_sub)
@mock.patch("synapse.module_api.ModuleApi.check_user_exists", return_value=False)
async def test_non_sysadmin_blocked_when_registration_disabled(self, *args):
"""JWT ``sub`` does not match any sysadmin ``external_id`` → no registration."""
token = get_jwt_token("aliceid", claims=default_claims)
result = await self.hs.mockmod.check_oauth(
"alice", "com.famedly.login.token.oauth", {"token": token}
)
self.assertEqual(result, None)

Expand Down Expand Up @@ -210,8 +255,8 @@ async def test_fetch_jwks(self, *args):

config_for_jwt_admin_path = deepcopy(config_for_jwt)
config_for_jwt_admin_path["modules"][0]["config"]["oauth"]["jwt_validation"][
"admin_path"
] = ["roles", "Admin"]
"admin_validator"
] = {"type": "in", "path": ["roles", "Admin"]}
config_for_jwt_admin_path["modules"][0]["config"]["oauth"][
"registration_enabled"
] = True
Expand All @@ -237,8 +282,14 @@ async def test_login_register_admin(self, register_user_mock, *args):

config_for_jwt_admin_paths = deepcopy(config_for_jwt)
config_for_jwt_admin_paths["modules"][0]["config"]["oauth"]["jwt_validation"][
"admin_path"
] = [["roles", "NotAdmin"], ["roles", "MatrixAdmin"]]
"admin_validator"
] = {
"type": "any_of",
"validators": [
{"type": "in", "path": ["roles", "NotAdmin"]},
{"type": "in", "path": ["roles", "MatrixAdmin"]},
],
}
config_for_jwt_admin_paths["modules"][0]["config"]["oauth"][
"registration_enabled"
] = True
Expand All @@ -264,8 +315,8 @@ async def test_login_register_multiple_admin_paths(self, register_user_mock, *ar

config_for_jwt_admin_path_wrong = deepcopy(config_for_jwt_admin_path)
config_for_jwt_admin_path_wrong["modules"][0]["config"]["oauth"]["jwt_validation"][
"admin_path"
] = ["roles", "SomethingAdmin"]
"admin_validator"
] = {"type": "in", "path": ["roles", "SomethingAdmin"]}

@synapsetest.override_config(config_for_jwt_admin_path_wrong)
@mock.patch("synapse.module_api.ModuleApi.check_user_exists", return_value=False)
Expand Down Expand Up @@ -404,6 +455,63 @@ async def test_login_check_external_id_disabled(self, *args):
]
}

config_for_introspection_reg_disabled_sysadmin = deepcopy(config_for_introspection)
config_for_introspection_reg_disabled_sysadmin["modules"][0]["config"]["oauth"][
"registration_enabled"
] = False
config_for_introspection_reg_disabled_sysadmin["modules"][0]["config"]["oauth"][
"sysadmins"
] = [
{"external_id": "aliceid", "issuer": "http://test.example"},
]

@synapsetest.override_config(config_for_introspection_reg_disabled_sysadmin)
@mock.patch(
"synapse.http.client.SimpleHttpClient.request", side_effect=mock_for_oauth
)
@mock.patch("synapse.module_api.ModuleApi.check_user_exists", return_value=False)
@mock.patch(
"synapse.http.client.SimpleHttpClient.post_json_get_json", return_value={}
)
@mock.patch(
"synapse.module_api.ModuleApi.record_user_external_id",
new_callable=mock.AsyncMock,
)
@mock.patch("synapse.module_api.ModuleApi.register_user")
async def test_sysadmin_registration_allowed_when_introspection_sub_and_iss_match(
self, register_user_mock, *args
):
"""``sysadmins`` can match introspection ``sub`` / ``iss`` when JWT is not validated."""
token = get_jwt_token("aliceid", claims=default_claims)
result = await self.hs.mockmod.check_oauth(
"alice", "com.famedly.login.token.oauth", {"token": token}
)
register_user_mock.assert_called_with("alice", admin=True)
self.assertEqual(result[0], "@alice:example.test")

config_for_introspection_reg_disabled_sysadmin_wrong = deepcopy(
config_for_introspection_reg_disabled_sysadmin
)
config_for_introspection_reg_disabled_sysadmin_wrong["modules"][0]["config"][
"oauth"
]["sysadmins"] = [
{"external_id": "not-aliceid", "issuer": "http://test.example"},
]

@synapsetest.override_config(config_for_introspection_reg_disabled_sysadmin_wrong)
@mock.patch(
"synapse.http.client.SimpleHttpClient.request", side_effect=mock_for_oauth
)
@mock.patch("synapse.module_api.ModuleApi.check_user_exists", return_value=False)
async def test_sysadmin_blocked_introspection_sub_mismatch_when_registration_disabled(
self, *args
):
token = get_jwt_token("aliceid", claims=default_claims)
result = await self.hs.mockmod.check_oauth(
"alice", "com.famedly.login.token.oauth", {"token": token}
)
self.assertEqual(result, None)

@synapsetest.override_config(config_for_introspection)
@mock.patch(
"synapse.http.client.SimpleHttpClient.request", side_effect=mock_for_oauth
Expand Down Expand Up @@ -481,7 +589,7 @@ async def test_login_introspection_invalid_scope(self, *args):
config_for_introspection_admin_path = deepcopy(config_for_introspection)
config_for_introspection_admin_path["modules"][0]["config"]["oauth"][
"introspection_validation"
]["admin_path"] = ["roles", "Admin"]
]["admin_validator"] = {"type": "in", "path": ["roles", "Admin"]}

@synapsetest.override_config(config_for_introspection_admin_path)
@mock.patch(
Expand All @@ -504,7 +612,13 @@ async def test_login_introspection_register_admin(self, register_user_mock, *arg
config_for_introspection_admin_paths = deepcopy(config_for_introspection)
config_for_introspection_admin_paths["modules"][0]["config"]["oauth"][
"introspection_validation"
]["admin_path"] = [["roles", "AnotherAdmin"], ["roles", "MatrixAdmin"]]
]["admin_validator"] = {
"type": "any_of",
"validators": [
{"type": "in", "path": ["roles", "AnotherAdmin"]},
{"type": "in", "path": ["roles", "MatrixAdmin"]},
],
}

@synapsetest.override_config(config_for_introspection_admin_paths)
@mock.patch(
Expand Down
Loading
Loading