diff --git a/README.md b/README.md index f4dd93f..f004129 100644 --- a/README.md +++ b/README.md @@ -11,27 +11,28 @@ Synapse Token Authenticator is a synapse auth provider which allows for token au * [Installation](#installation) * [Configuration](#configuration) - * [OAuthConfig](#oauthconfig) - * [JwtValidationConfig](#jwtvalidationconfig) - * [IntrospectionValidationConfig](#introspectionvalidationconfig) - * [NotifyOnRegistration](#notifyonregistration) - * [Path](#path) - * [BasicAuth](#basicauth) - * [BearerAuth](#bearerauth) - * [HttpAuth](#httpauth) - * [Validator](#validator) - * [Exist](#exist) - * [Not](#not) - * [Equal](#equal) - * [MatchesRegex](#matchesregex) - * [AnyOf](#anyof) - * [AllOf](#allof) - * [In](#in) - * [ListAllOf](#listallof) - * [ListAnyOf](#listanyof) + * [OAuthConfig](#oauthconfig) + * [OAuthSysadmin](#oauthsysadmin) + * [JwtValidationConfig](#jwtvalidationconfig) + * [IntrospectionValidationConfig](#introspectionvalidationconfig) + * [NotifyOnRegistration](#notifyonregistration) + * [Path](#path) + * [BasicAuth](#basicauth) + * [BearerAuth](#bearerauth) + * [HttpAuth](#httpauth) + * [Validator](#validator) + * [Exist](#exist) + * [Not](#not) + * [Equal](#equal) + * [MatchesRegex](#matchesregex) + * [AnyOf](#anyof) + * [AllOf](#allof) + * [In](#in) + * [ListAllOf](#listallof) + * [ListAnyOf](#listanyof) * [Usage](#usage) - * [JWT Authentication](#jwt-authentication) - * [OIDC Authentication](#oidc-authentication) + * [JWT Authentication](#jwt-authentication) + * [OIDC Authentication](#oidc-authentication) * [Testing](#testing) * [Releasing](#releasing) * [License](#license) @@ -43,7 +44,9 @@ pip install synapse-token-authenticator ``` ## Configuration + Here are the available configuration options: + ```yaml jwt: # provide only one of secret, keyfile @@ -74,9 +77,11 @@ epa: oauth: # see OAuthConfig section ``` + It is recommended to have `require_expiry` set to `true` (default). As for `allow_registration`, it depends on usecase: If you only want to be able to log in *existing* users, leave it at `false` (default). If nonexistant users should be simply registered upon hitting the login endpoint, set it to `true`. ### OAuthConfig + | Parameter | Type | | -------------------------- | ---------------------------------------------------------------------------- | | `jwt_validation` | [`JwtValidationConfig`](#jwtvalidationconfig) (optional) | @@ -85,16 +90,19 @@ It is recommended to have `require_expiry` set to `true` (default). As for `allo | `notify_on_registration` | [`NotifyOnRegistration`](#notifyonregistration) (optional) | | `expose_metadata_resource` | Any (optional) | | `registration_enabled` | Bool (defaults to `false`) | +| `sysadmins` | List of [`OAuthSysadmin`](#oauthsysadmin) (optional) | At least one of `jwt_validation` or `introspection_validation` must be defined. `username_type` specifies the role of `identifier.user`: -- `'fq_uid'` — must be fully qualified username, e.g. `@alice:example.test` -- `'localpart'` — must be localpart, e.g. `alice` -- `'user_id'` — could be localpart or fully qualified username -- `null` — the username is ignored, it will be source from the token or introspection response + +* `'fq_uid'` — must be fully qualified username, e.g. `@alice:example.test` +* `'localpart'` — must be localpart, e.g. `alice` +* `'user_id'` — could be localpart or fully qualified username +* `null` — the username is ignored, it will be source from the token or introspection response If `notify_on_registration` is set then `notify_on_registration.url` will be called when a new user is registered with this body: + ```json { "localpart": "alice", @@ -105,10 +113,33 @@ If `notify_on_registration` is set then `notify_on_registration.url` will be cal `expose_metadata_resource` must be an object with `name` field. The object will be exposed at `/_famedly/login/{expose_metadata_resource.name}`. +When `registration_enabled` is `false`, new users cannot register through this OAuth flow—except for identities listed under `sysadmins`. A `sysadmins` entry matches when both `external_id` and `issuer` equal the token's resolved `sub` and `iss`. On **first registration**, a matching sysadmin is created as a Synapse server admin, in addition to any `admin_validator` outcome. + +Example: + +```yaml +oauth: + registration_enabled: false + sysadmins: + - external_id: "366575164390899726" + issuer: "https://zitadel.example.com" + jwt_validation: + # ... +``` + `jwt_validation` and `introspection_validation` contain a bunch of `*_path` optional fields. Each of these, if specified will be used to source either localpart, user id, fully qualified user id, admin permission, or email from jwt claims and introspection response. They values are going to be compared for equality, if they differ, authentication would fail. Be careful with these, as it is possible to configure in such a way that authentication would always fail, or, if `username_type` is `null`, no user id data can be sourced, thus also leading to failure. +### OAuthSysadmin + +Each object in `oauth.sysadmins` identifies one IdP subject that may register even when `registration_enabled` is `false`, and who is registered as a server admin when first created via this flow. + +| Parameter | Description | +| ------------- | ----------- | +| `external_id` | Must match the resolved `sub` claim. | +| `issuer` | Must match the resolved `iss` claim. | ### JwtValidationConfig + [RFC 7519 - JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519) | Parameter | Type | @@ -119,7 +150,7 @@ If `notify_on_registration` is set then `notify_on_registration.url` will be cal | `user_id_path` | [`Path`](#path) (optional) | | `fq_uid_path` | [`Path`](#path) (optional) | | `displayname_path` | [`Path`](#path) (optional) | -| `admin_path` | [`PathList`](#pathlist) (optional) | +| `admin_validator` | [`Validator`](#validator) (optional) | | `email_path` | [`Path`](#path) (optional) | | `required_scopes` | Space separated string or a list of strings (optional) | | `jwk_set` | [JWKSet](https://datatracker.ietf.org/doc/html/rfc7517#section-5) or [JWK](https://datatracker.ietf.org/doc/html/rfc7517#section-4) (optional) | @@ -128,8 +159,12 @@ If `notify_on_registration` is set then `notify_on_registration.url` will be cal Either `jwk_set` or `jwk_file` or `jwks_endpoint` must be specified. +If `admin_validator` is set, it is run against the decoded JWT claims when registering a new user. If it returns true, the user is created as a server admin. + ### IntrospectionValidationConfig + [RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662) + | Parameter | Type | | ------------------ | --------------------------------------------------------- | | `endpoint` | String | @@ -139,11 +174,14 @@ Either `jwk_set` or `jwk_file` or `jwks_endpoint` must be specified. | `user_id_path` | [`Path`](#path) (optional) | | `fq_uid_path` | [`Path`](#path) (optional) | | `displayname_path` | [`Path`](#path) (optional) | -| `admin_path` | [`PathList`](#pathlist) (optional) | +| `admin_validator` | [`Validator`](#validator) (optional) | | `email_path` | [`Path`](#path) (optional) | | `required_scopes` | Space separated string or a list of strings (optional) | +If `admin_validator` is set, it is run against the introspection JSON when registering a new user; a true result creates the user as admin. + Keep in mind, that default validator will always pass. According to the [spec](https://datatracker.ietf.org/doc/html/rfc7662), you probably want at least + ```yaml type: in path: 'active' @@ -151,91 +189,112 @@ validator: type: equal value: true ``` + or + ```yaml ['in', 'active', ['equal', true]] ``` -### NotifyOnRegistration: +### NotifyOnRegistration + | Parameter | Type | | -------------------- | ---------------------------------- | | `url` | String | -| `auth` | [`HttpAuth`](#HttpAuth) (optional) | +| `auth` | [`HttpAuth`](#httpauth) (optional) | | `interrupt_on_error` | Bool (defaults to `true`) | ### Path + A path is either a string or a list of strings. A path is used to get a value inside a nested dictionary/object. ### PathList + A path is either a string, a list of strings or a list of a list of strings. A pathlist is used to get a value inside a nested dictionary/object. If it's a string or a list of string it will behave just like a Path. If it's a list of lists it will handle every list as a Path and return the value gotten by the first Path that gets a not `None` value. For example, given the PathList `[["a", "b"], ["c", "d"]]` and the dictionary `{"a":{"e": 1}, "c":{"d":2}}`, the PathList will get the value 2. #### Examples -- `'foo'` is an existing path in `{'foo': 3}`, resulting in value `3` -- `['foo']` is an existing path in `{'foo': 3}`, resulting in value `3` -- `['foo', 'bar']` is an existing path in `{'foo': {'bar': 3}}`, resulting in value `3` + +* `'foo'` is an existing path in `{'foo': 3}`, resulting in value `3` + +* `['foo']` is an existing path in `{'foo': 3}`, resulting in value `3` +* `['foo', 'bar']` is an existing path in `{'foo': {'bar': 3}}`, resulting in value `3` ### BasicAuth + | Parameter | Type | | ---------- | ------ | | `username` | String | | `password` | String | ### BearerAuth + | Parameter | Type | | --------- | ------ | | `token` | String | ### HttpAuth + Authentication options, always optional + | Parameter | Type | | --------- | ----------------------- | | `type` | `'basic'` \| `'bearer'` | -Possible options: [`BasicAuth`](#BasicAuth), [`BearerAuth`](#BearerAuth), +Possible options: [`BasicAuth`](#basicauth), [`BearerAuth`](#bearerauth), ### Validator + A validator is any of these types: - [`Exist`](#Exist), - [`Not`](#Not), - [`Equal`](#Equal), - [`MatchesRegex`](#MatchesRegex), - [`AnyOf`](#AnyOf), - [`AllOf`](#AllOf), - [`In`](#In), - [`ListAnyOf`](#ListAnyOf), - [`ListAllOf`](#ListAllOf) + [`Exist`](#exist), + [`Not`](#not), + [`Equal`](#equal), + [`MatchesRegex`](#matchesregex), + [`AnyOf`](#anyof), + [`AllOf`](#allof), + [`In`](#in), + [`ListAnyOf`](#listanyof), + [`ListAllOf`](#listallof) Each validator has `type` field ### Exist + Validator that always returns true. #### Examples + ```yaml {'type': 'exist'} ``` + or + ```yaml ['exist'] ``` ### Not + Validator that inverses the result of the inner validator. | Parameter | Type | | ----------- | ------------------------- | -| `validator` | [`Validator`](#Validator) | +| `validator` | [`Validator`](#validator) | #### Examples + ```yaml {'type': 'not', 'validator': 'exist'} ``` + or + ```yaml ['not', 'exist'] ``` ### Equal + Validator that checks for equality with the specified constant. | Parameter | Type | @@ -243,15 +302,19 @@ Validator that checks for equality with the specified constant. | `value` | `Any` | #### Examples + ```yaml {'type': 'equal', 'value': 3} ``` + or + ```yaml ['equal', 3] ``` ### MatchesRegex + Validator that checks if a value is a string and matches the specified regex. | Parameter | Type | Description | @@ -260,101 +323,121 @@ Validator that checks if a value is a string and matches the specified regex. | `full_match` (optional, `true` by default) | `bool` | Full match or partial match | #### Examples + ```yaml {'type': 'regex', 'regex': 'hello.'} ``` + or + ```yaml ['regex', 'hello.', false] ``` ### AnyOf -Validator that checks if **any** of the inner validators pass. +Validator that checks if **any** of the inner validators pass. | Parameter | Type | | ------------ | --------------------------------- | -| `validators` | List of [`Validator`](#Validator) | +| `validators` | List of [`Validator`](#validator) | #### Examples + ```yaml type: any_of validators: - ['in', 'foo', ['equal', 3]] - ['in', 'bar' ['exist']] ``` + or + ```yaml ['any_of', [['in', 'bar' ['exist']], ['in', 'foo', ['equal', 3]]]] ``` ### AllOf + Validator that checks if **all** of the inner validators pass. | Parameter | Type | | ------------ | --------------------------------- | -| `validators` | List of [`Validator`](#Validator) | +| `validators` | List of [`Validator`](#validator) | #### Examples + ```yaml type: all_of validators: - ['exist'] - ['in', 'foo', ['equal', 3]] ``` + or + ```yaml ['all_of', [['exist'], ['in', 'foo', ['equal', 3]]]] ``` ### In + Validator that modifies the context for the inner validator, *going inside* a dict key. If the validated object is not a dict, or doesn't have specified `path`, validation fails. | Parameter | Type | | ----------- | ------------------------------------------------------------------- | -| `path` | [`Path`](#Path) | -| `validator` | [`Validator`](#Validator) (optional, defaults to [`Exist`](#Exist)) | +| `path` | [`Path`](#path) | +| `validator` | [`Validator`](#validator) (optional, defaults to [`Exist`](#exist)) | #### Examples + ```yaml ['in', ['foo', 'bar'], ['equal', 3]] ``` ### ListAllOf + Validator that checks if the value is a list and **all** of its elements satisfy the specified validator. | Parameter | Type | | ----------- | ------------------------- | -| `validator` | [`Validator`](#Validator) | +| `validator` | [`Validator`](#validator) | #### Examples + ```yaml type: list_all_of validator: type: regex regex: 'ab..' ``` + or + ```yaml ['list_all_of', ['regex', 'ab..']] ``` ### ListAnyOf + Validator that checks if the value is a list and if **any** of its elements satisfy the specified validator. | Parameter | Type | | ----------- | ------------------------- | -| `validator` | [`Validator`](#Validator) | +| `validator` | [`Validator`](#validator) | #### Examples + ```yaml type: list_all_of validator: type: equal value: 3 ``` + or + ```yaml ['list_any_of', ['equal', 3]] ``` @@ -391,7 +474,9 @@ If `lowercase_localpart` is set to `true` the flow will transform all localparts ## Usage ### JWT Authentication + First you have to generate a JWT with the correct claims. The `sub` claim is the localpart or full mxid of the user you want to log in as. Be sure that the algorithm and secret match those of the configuration. An example of the claims is as follows: + ```json { "sub": "alice", @@ -400,6 +485,7 @@ First you have to generate a JWT with the correct claims. The `sub` claim is the ``` Next you need to post this token to the `/login` endpoint of synapse. Be sure that the `type` is `com.famedly.login.token` and that `identifier.user` is, again, either the localpart or the full mxid. For example the post body could look as following: + ```json { "type": "com.famedly.login.token", @@ -410,9 +496,11 @@ Next you need to post this token to the `/login` endpoint of synapse. Be sure th "token": "" } ``` + ### OIDC Authentication First, the user needs to obtain an Access token and an ID token from the IDP: + ```http POST https://idp.example.org/oauth/v2/token diff --git a/synapse_token_authenticator/config.py b/synapse_token_authenticator/config.py index dce1f7d..ec9ce04 100644 --- a/synapse_token_authenticator/config.py +++ b/synapse_token_authenticator/config.py @@ -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: @@ -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 @@ -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: @@ -98,7 +100,7 @@ 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 @@ -106,6 +108,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 not isinstance(self.auth, NoAuth): self.auth = parse_auth(self.auth) @@ -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 @@ -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 diff --git a/synapse_token_authenticator/token_authenticator.py b/synapse_token_authenticator/token_authenticator.py index e847fa2..a89a536 100644 --- a/synapse_token_authenticator/token_authenticator.py +++ b/synapse_token_authenticator/token_authenticator.py @@ -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) @@ -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 @@ -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: diff --git a/tests/__init__.py b/tests/__init__.py index 0fbf1b1..6f2db1c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -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 diff --git a/tests/server.py b/tests/server.py index 19f5c23..b7f1eba 100644 --- a/tests/server.py +++ b/tests/server.py @@ -15,6 +15,7 @@ # ruff: noqa: ARG001 ARG002 import hashlib +import inspect import ipaddress import logging import os @@ -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 ( @@ -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 @@ -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] diff --git a/tests/synapse_clock.py b/tests/synapse_clock.py new file mode 100644 index 0000000..4a95426 --- /dev/null +++ b/tests/synapse_clock.py @@ -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"] diff --git a/tests/test_oauth.py b/tests/test_oauth.py index cf90373..846934c 100644 --- a/tests/test_oauth.py +++ b/tests/test_oauth.py @@ -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) @@ -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 @@ -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 @@ -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) @@ -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 @@ -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( @@ -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( diff --git a/tests/unittest.py b/tests/unittest.py index d28243a..892ad61 100644 --- a/tests/unittest.py +++ b/tests/unittest.py @@ -18,6 +18,7 @@ import functools import gc +import inspect import json import logging from collections.abc import Awaitable, Callable @@ -42,7 +43,7 @@ from synapse.rest import RegisterServletsFunc from synapse.server import HomeServer from synapse.types import JsonDict -from synapse.util.clock import Clock +from tests.synapse_clock import Clock from synapse.util.httpresourcetree import create_resource_tree from twisted.internet.defer import Deferred, ensureDeferred from twisted.internet.testing import MemoryReactor @@ -387,9 +388,10 @@ def setup_test_homeserver( kwargs["name"] = config_obj.server.server_name async def run_bg_updates() -> None: - with LoggingContext( - name="run_bg_updates", server_name=config_obj.server.server_name - ): + _log_ctx_kw: dict = {"name": "run_bg_updates"} + if "server_name" in inspect.signature(LoggingContext.__init__).parameters: + _log_ctx_kw["server_name"] = config_obj.server.server_name + with LoggingContext(**_log_ctx_kw): self.get_success(stor.db_pool.updates.run_background_updates(False)) hs = setup_test_homeserver(**kwargs)