Skip to content

Commit db33530

Browse files
committed
feat!: Change admin_path to admin_validator
1 parent bba345d commit db33530

4 files changed

Lines changed: 67 additions & 34 deletions

File tree

README.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ If `notify_on_registration` is set then `notify_on_registration.url` will be cal
113113

114114
`expose_metadata_resource` must be an object with `name` field. The object will be exposed at `/_famedly/login/{expose_metadata_resource.name}`.
115115

116-
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`.
116+
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.
117117

118118
Example:
119119

@@ -131,7 +131,7 @@ oauth:
131131

132132
### OAuthSysadmin
133133

134-
Each object in `oauth.sysadmins` identifies one IdP subject that may register even when `registration_enabled` is `false`.
134+
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.
135135

136136
| Parameter | Description |
137137
| ------------- | ----------- |
@@ -150,7 +150,7 @@ Each object in `oauth.sysadmins` identifies one IdP subject that may register ev
150150
| `user_id_path` | [`Path`](#path) (optional) |
151151
| `fq_uid_path` | [`Path`](#path) (optional) |
152152
| `displayname_path` | [`Path`](#path) (optional) |
153-
| `admin_path` | [`PathList`](#pathlist) (optional) |
153+
| `admin_validator` | [`Validator`](#validator) (optional) |
154154
| `email_path` | [`Path`](#path) (optional) |
155155
| `required_scopes` | Space separated string or a list of strings (optional) |
156156
| `jwk_set` | [JWKSet](https://datatracker.ietf.org/doc/html/rfc7517#section-5) or [JWK](https://datatracker.ietf.org/doc/html/rfc7517#section-4) (optional) |
@@ -159,6 +159,8 @@ Each object in `oauth.sysadmins` identifies one IdP subject that may register ev
159159

160160
Either `jwk_set` or `jwk_file` or `jwks_endpoint` must be specified.
161161

162+
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.
163+
162164
### IntrospectionValidationConfig
163165

164166
[RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
@@ -172,10 +174,12 @@ Either `jwk_set` or `jwk_file` or `jwks_endpoint` must be specified.
172174
| `user_id_path` | [`Path`](#path) (optional) |
173175
| `fq_uid_path` | [`Path`](#path) (optional) |
174176
| `displayname_path` | [`Path`](#path) (optional) |
175-
| `admin_path` | [`PathList`](#pathlist) (optional) |
177+
| `admin_validator` | [`Validator`](#validator) (optional) |
176178
| `email_path` | [`Path`](#path) (optional) |
177179
| `required_scopes` | Space separated string or a list of strings (optional) |
178180

181+
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.
182+
179183
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
180184

181185
```yaml

synapse_token_authenticator/config.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,6 @@ def __init__(self, other: dict):
5858
if config := other.get("oauth"):
5959

6060
Path: TypeAlias = Union[str, List[str]]
61-
PathList: TypeAlias = Union[Path, List[List[str]]]
6261

6362
@dataclass
6463
class JwtValidationConfig:
@@ -68,7 +67,7 @@ class JwtValidationConfig:
6867
user_id_path: Path | None = None
6968
fq_uid_path: Path | None = None
7069
displayname_path: Path | None = None
71-
admin_path: PathList | None = None
70+
admin_validator: Validator | None = None
7271
email_path: Path | None = None
7372
required_scopes: str | List[str] | None = None
7473
jwk_set: JWKSet | JWK | None = None
@@ -79,6 +78,9 @@ def __post_init__(self):
7978
if not isinstance(self.validator, Exist):
8079
self.validator = parse_validator(self.validator)
8180

81+
if self.admin_validator is not None:
82+
self.admin_validator = parse_validator(self.admin_validator)
83+
8284
if self.jwk_set and ("keys" in self.jwk_set):
8385
self.jwk_set = JWKSet(**self.jwk_set)
8486
elif self.jwk_set:
@@ -98,14 +100,17 @@ class IntrospectionValidationConfig:
98100
user_id_path: Path | None = None
99101
fq_uid_path: Path | None = None
100102
displayname_path: Path | None = None
101-
admin_path: PathList | None = None
103+
admin_validator: Validator | None = None
102104
email_path: Path | None = None
103105
required_scopes: str | List[str] | None = None
104106

105107
def __post_init__(self):
106108
if not isinstance(self.validator, Exist):
107109
self.validator = parse_validator(self.validator)
108110

111+
if self.admin_validator is not None:
112+
self.admin_validator = parse_validator(self.admin_validator)
113+
109114
if not isinstance(self.auth, NoAuth):
110115
self.auth = parse_auth(self.auth)
111116

@@ -138,9 +143,11 @@ class OAuthConfig:
138143
def __post_init__(self):
139144
if self.sysadmins:
140145
self.sysadmins = [
141-
OAuthSysadminConfig(**entry)
142-
if isinstance(entry, dict)
143-
else entry
146+
(
147+
OAuthSysadminConfig(**entry)
148+
if isinstance(entry, dict)
149+
else entry
150+
)
144151
for entry in self.sysadmins
145152
]
146153
if self.notify_on_registration:

synapse_token_authenticator/token_authenticator.py

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -497,18 +497,22 @@ def get_from_set(set_):
497497
return None
498498

499499
try:
500-
get_admin_mb = if_not_none(lambda x: x.admin_path)
500+
get_admin_mb = if_not_none(lambda x: x.admin_validator)
501+
502+
def admin_result(claims: dict, validation_cfg) -> bool | None:
503+
v = get_admin_mb(validation_cfg)
504+
return v.validate(claims) if v is not None else None
505+
501506
admin = all_list_elems_are_equal_return_the_elem(
502507
[
503-
get_from_set(jwt_claims)(get_admin_mb(config.jwt_validation)),
504-
get_from_set(introspection_claims)(
505-
get_admin_mb(config.introspection_validation)
506-
),
508+
admin_result(jwt_claims, config.jwt_validation),
509+
admin_result(introspection_claims, config.introspection_validation),
507510
]
508511
)
509512
except Exception as e:
510513
logger.info(e)
511514
return None
515+
admin = bool(admin)
512516

513517
try:
514518
get_email_mb = if_not_none(lambda x: x.email_path)
@@ -554,15 +558,14 @@ def get_from_set(set_):
554558

555559
user_exists = await self.api.check_user_exists(fully_qualified_uid)
556560

557-
registration_allowed = config.registration_enabled or (
558-
config.sysadmins is not None
559-
and any(
560-
str(s.external_id) == str(external_id)
561-
and str(s.issuer) == str(auth_provider)
562-
for s in config.sysadmins
563-
)
561+
oauth_sysadmin_match = config.sysadmins is not None and any(
562+
str(s.external_id) == str(external_id)
563+
and str(s.issuer) == str(auth_provider)
564+
for s in config.sysadmins
564565
)
565566

567+
registration_allowed = config.registration_enabled or oauth_sysadmin_match
568+
566569
if not user_exists and not registration_allowed:
567570
logger.info("User doesn't exist and registration is disabled")
568571
return None
@@ -587,9 +590,10 @@ def get_from_set(set_):
587590
if config.notify_on_registration.interrupt_on_error:
588591
return None
589592

590-
user_id = await self.api.register_user(localpart, admin=bool(admin))
593+
register_as_admin = bool(admin) or oauth_sysadmin_match
594+
user_id = await self.api.register_user(localpart, admin=register_as_admin)
591595
logger.debug(
592-
f"User '{localpart}' created as '{'Admin' if bool(admin) else 'User'}'"
596+
f"User '{localpart}' created as '{'Admin' if register_as_admin else 'User'}'"
593597
)
594598

595599
if email:

tests/test_oauth.py

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,12 +149,16 @@ async def test_valid_login_registration_disabled(self, *args):
149149
"synapse.module_api.ModuleApi.record_user_external_id",
150150
new_callable=mock.AsyncMock,
151151
)
152-
async def test_sysadmin_registration_allowed_when_jwt_sub_and_iss_match(self, *args):
152+
@mock.patch("synapse.module_api.ModuleApi.register_user")
153+
async def test_sysadmin_registration_allowed_when_jwt_sub_and_iss_match(
154+
self, register_user_mock, *args
155+
):
153156
"""Registration is allowed when ``sysadmins`` matches JWT ``sub`` / ``iss``."""
154157
token = get_jwt_token("aliceid", claims=default_claims)
155158
result = await self.hs.mockmod.check_oauth(
156159
"alice", "com.famedly.login.token.oauth", {"token": token}
157160
)
161+
register_user_mock.assert_called_with("alice", admin=True)
158162
self.assertEqual(result[0], "@alice:example.test")
159163

160164
config_for_jwt_reg_disabled_sysadmin_wrong_sub = deepcopy(
@@ -251,8 +255,8 @@ async def test_fetch_jwks(self, *args):
251255

252256
config_for_jwt_admin_path = deepcopy(config_for_jwt)
253257
config_for_jwt_admin_path["modules"][0]["config"]["oauth"]["jwt_validation"][
254-
"admin_path"
255-
] = ["roles", "Admin"]
258+
"admin_validator"
259+
] = {"type": "in", "path": ["roles", "Admin"]}
256260
config_for_jwt_admin_path["modules"][0]["config"]["oauth"][
257261
"registration_enabled"
258262
] = True
@@ -278,8 +282,14 @@ async def test_login_register_admin(self, register_user_mock, *args):
278282

279283
config_for_jwt_admin_paths = deepcopy(config_for_jwt)
280284
config_for_jwt_admin_paths["modules"][0]["config"]["oauth"]["jwt_validation"][
281-
"admin_path"
282-
] = [["roles", "NotAdmin"], ["roles", "MatrixAdmin"]]
285+
"admin_validator"
286+
] = {
287+
"type": "any_of",
288+
"validators": [
289+
{"type": "in", "path": ["roles", "NotAdmin"]},
290+
{"type": "in", "path": ["roles", "MatrixAdmin"]},
291+
],
292+
}
283293
config_for_jwt_admin_paths["modules"][0]["config"]["oauth"][
284294
"registration_enabled"
285295
] = True
@@ -305,8 +315,8 @@ async def test_login_register_multiple_admin_paths(self, register_user_mock, *ar
305315

306316
config_for_jwt_admin_path_wrong = deepcopy(config_for_jwt_admin_path)
307317
config_for_jwt_admin_path_wrong["modules"][0]["config"]["oauth"]["jwt_validation"][
308-
"admin_path"
309-
] = ["roles", "SomethingAdmin"]
318+
"admin_validator"
319+
] = {"type": "in", "path": ["roles", "SomethingAdmin"]}
310320

311321
@synapsetest.override_config(config_for_jwt_admin_path_wrong)
312322
@mock.patch("synapse.module_api.ModuleApi.check_user_exists", return_value=False)
@@ -467,14 +477,16 @@ async def test_login_check_external_id_disabled(self, *args):
467477
"synapse.module_api.ModuleApi.record_user_external_id",
468478
new_callable=mock.AsyncMock,
469479
)
480+
@mock.patch("synapse.module_api.ModuleApi.register_user")
470481
async def test_sysadmin_registration_allowed_when_introspection_sub_and_iss_match(
471-
self, *args
482+
self, register_user_mock, *args
472483
):
473484
"""``sysadmins`` can match introspection ``sub`` / ``iss`` when JWT is not validated."""
474485
token = get_jwt_token("aliceid", claims=default_claims)
475486
result = await self.hs.mockmod.check_oauth(
476487
"alice", "com.famedly.login.token.oauth", {"token": token}
477488
)
489+
register_user_mock.assert_called_with("alice", admin=True)
478490
self.assertEqual(result[0], "@alice:example.test")
479491

480492
config_for_introspection_reg_disabled_sysadmin_wrong = deepcopy(
@@ -577,7 +589,7 @@ async def test_login_introspection_invalid_scope(self, *args):
577589
config_for_introspection_admin_path = deepcopy(config_for_introspection)
578590
config_for_introspection_admin_path["modules"][0]["config"]["oauth"][
579591
"introspection_validation"
580-
]["admin_path"] = ["roles", "Admin"]
592+
]["admin_validator"] = {"type": "in", "path": ["roles", "Admin"]}
581593

582594
@synapsetest.override_config(config_for_introspection_admin_path)
583595
@mock.patch(
@@ -600,7 +612,13 @@ async def test_login_introspection_register_admin(self, register_user_mock, *arg
600612
config_for_introspection_admin_paths = deepcopy(config_for_introspection)
601613
config_for_introspection_admin_paths["modules"][0]["config"]["oauth"][
602614
"introspection_validation"
603-
]["admin_path"] = [["roles", "AnotherAdmin"], ["roles", "MatrixAdmin"]]
615+
]["admin_validator"] = {
616+
"type": "any_of",
617+
"validators": [
618+
{"type": "in", "path": ["roles", "AnotherAdmin"]},
619+
{"type": "in", "path": ["roles", "MatrixAdmin"]},
620+
],
621+
}
604622

605623
@synapsetest.override_config(config_for_introspection_admin_paths)
606624
@mock.patch(

0 commit comments

Comments
 (0)