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

Commit 8b40843

Browse files
authored
Allow additional SSO properties to be passed to the client (#8413)
1 parent ceafb5a commit 8b40843

File tree

9 files changed

+278
-67
lines changed

9 files changed

+278
-67
lines changed

changelog.d/8413.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Support passing additional single sign-on parameters to the client.

docs/sample_config.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1748,6 +1748,14 @@ oidc_config:
17481748
#
17491749
#display_name_template: "{{ user.given_name }} {{ user.last_name }}"
17501750

1751+
# Jinja2 templates for extra attributes to send back to the client during
1752+
# login.
1753+
#
1754+
# Note that these are non-standard and clients will ignore them without modifications.
1755+
#
1756+
#extra_attributes:
1757+
#birthdate: "{{ user.birthdate }}"
1758+
17511759

17521760

17531761
# Enable CAS for registration and login.

docs/sso_mapping_providers.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ A custom mapping provider must specify the following methods:
5757
- This method must return a string, which is the unique identifier for the
5858
user. Commonly the ``sub`` claim of the response.
5959
* `map_user_attributes(self, userinfo, token)`
60-
- This method should be async.
60+
- This method must be async.
6161
- Arguments:
6262
- `userinfo` - A `authlib.oidc.core.claims.UserInfo` object to extract user
6363
information from.
@@ -66,6 +66,18 @@ A custom mapping provider must specify the following methods:
6666
- Returns a dictionary with two keys:
6767
- localpart: A required string, used to generate the Matrix ID.
6868
- displayname: An optional string, the display name for the user.
69+
* `get_extra_attributes(self, userinfo, token)`
70+
- This method must be async.
71+
- Arguments:
72+
- `userinfo` - A `authlib.oidc.core.claims.UserInfo` object to extract user
73+
information from.
74+
- `token` - A dictionary which includes information necessary to make
75+
further requests to the OpenID provider.
76+
- Returns a dictionary that is suitable to be serialized to JSON. This
77+
will be returned as part of the response during a successful login.
78+
79+
Note that care should be taken to not overwrite any of the parameters
80+
usually returned as part of the [login response](https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login).
6981

7082
### Default OpenID Mapping Provider
7183

docs/workers.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,22 @@ for the room are in flight:
243243

244244
^/_matrix/client/(api/v1|r0|unstable)/rooms/.*/messages$
245245

246+
Additionally, the following endpoints should be included if Synapse is configured
247+
to use SSO (you only need to include the ones for whichever SSO provider you're
248+
using):
249+
250+
# OpenID Connect requests.
251+
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect$
252+
^/_synapse/oidc/callback$
253+
254+
# SAML requests.
255+
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect$
256+
^/_matrix/saml2/authn_response$
257+
258+
# CAS requests.
259+
^/_matrix/client/(api/v1|r0|unstable)/login/(cas|sso)/redirect$
260+
^/_matrix/client/(api/v1|r0|unstable)/login/cas/ticket$
261+
246262
Note that a HTTP listener with `client` and `federation` resources must be
247263
configured in the `worker_listeners` option in the worker config.
248264

synapse/config/oidc_config.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,14 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
204204
# If unset, no displayname will be set.
205205
#
206206
#display_name_template: "{{{{ user.given_name }}}} {{{{ user.last_name }}}}"
207+
208+
# Jinja2 templates for extra attributes to send back to the client during
209+
# login.
210+
#
211+
# Note that these are non-standard and clients will ignore them without modifications.
212+
#
213+
#extra_attributes:
214+
#birthdate: "{{{{ user.birthdate }}}}"
207215
""".format(
208216
mapping_provider=DEFAULT_USER_MAPPING_PROVIDER
209217
)

synapse/handlers/auth.py

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,15 @@ def login_id_phone_to_thirdparty(identifier: JsonDict) -> Dict[str, str]:
137137
}
138138

139139

140+
@attr.s(slots=True)
141+
class SsoLoginExtraAttributes:
142+
"""Data we track about SAML2 sessions"""
143+
144+
# time the session was created, in milliseconds
145+
creation_time = attr.ib(type=int)
146+
extra_attributes = attr.ib(type=JsonDict)
147+
148+
140149
class AuthHandler(BaseHandler):
141150
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000
142151

@@ -239,6 +248,10 @@ def __init__(self, hs):
239248
# cast to tuple for use with str.startswith
240249
self._whitelisted_sso_clients = tuple(hs.config.sso_client_whitelist)
241250

251+
# A mapping of user ID to extra attributes to include in the login
252+
# response.
253+
self._extra_attributes = {} # type: Dict[str, SsoLoginExtraAttributes]
254+
242255
async def validate_user_via_ui_auth(
243256
self,
244257
requester: Requester,
@@ -1165,6 +1178,7 @@ async def complete_sso_login(
11651178
registered_user_id: str,
11661179
request: SynapseRequest,
11671180
client_redirect_url: str,
1181+
extra_attributes: Optional[JsonDict] = None,
11681182
):
11691183
"""Having figured out a mxid for this user, complete the HTTP request
11701184
@@ -1173,6 +1187,8 @@ async def complete_sso_login(
11731187
request: The request to complete.
11741188
client_redirect_url: The URL to which to redirect the user at the end of the
11751189
process.
1190+
extra_attributes: Extra attributes which will be passed to the client
1191+
during successful login. Must be JSON serializable.
11761192
"""
11771193
# If the account has been deactivated, do not proceed with the login
11781194
# flow.
@@ -1181,19 +1197,30 @@ async def complete_sso_login(
11811197
respond_with_html(request, 403, self._sso_account_deactivated_template)
11821198
return
11831199

1184-
self._complete_sso_login(registered_user_id, request, client_redirect_url)
1200+
self._complete_sso_login(
1201+
registered_user_id, request, client_redirect_url, extra_attributes
1202+
)
11851203

11861204
def _complete_sso_login(
11871205
self,
11881206
registered_user_id: str,
11891207
request: SynapseRequest,
11901208
client_redirect_url: str,
1209+
extra_attributes: Optional[JsonDict] = None,
11911210
):
11921211
"""
11931212
The synchronous portion of complete_sso_login.
11941213
11951214
This exists purely for backwards compatibility of synapse.module_api.ModuleApi.
11961215
"""
1216+
# Store any extra attributes which will be passed in the login response.
1217+
# Note that this is per-user so it may overwrite a previous value, this
1218+
# is considered OK since the newest SSO attributes should be most valid.
1219+
if extra_attributes:
1220+
self._extra_attributes[registered_user_id] = SsoLoginExtraAttributes(
1221+
self._clock.time_msec(), extra_attributes,
1222+
)
1223+
11971224
# Create a login token
11981225
login_token = self.macaroon_gen.generate_short_term_login_token(
11991226
registered_user_id
@@ -1226,6 +1253,37 @@ def _complete_sso_login(
12261253
)
12271254
respond_with_html(request, 200, html)
12281255

1256+
async def _sso_login_callback(self, login_result: JsonDict) -> None:
1257+
"""
1258+
A login callback which might add additional attributes to the login response.
1259+
1260+
Args:
1261+
login_result: The data to be sent to the client. Includes the user
1262+
ID and access token.
1263+
"""
1264+
# Expire attributes before processing. Note that there shouldn't be any
1265+
# valid logins that still have extra attributes.
1266+
self._expire_sso_extra_attributes()
1267+
1268+
extra_attributes = self._extra_attributes.get(login_result["user_id"])
1269+
if extra_attributes:
1270+
login_result.update(extra_attributes.extra_attributes)
1271+
1272+
def _expire_sso_extra_attributes(self) -> None:
1273+
"""
1274+
Iterate through the mapping of user IDs to extra attributes and remove any that are no longer valid.
1275+
"""
1276+
# TODO This should match the amount of time the macaroon is valid for.
1277+
LOGIN_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000
1278+
expire_before = self._clock.time_msec() - LOGIN_TOKEN_EXPIRATION_TIME
1279+
to_expire = set()
1280+
for user_id, data in self._extra_attributes.items():
1281+
if data.creation_time < expire_before:
1282+
to_expire.add(user_id)
1283+
for user_id in to_expire:
1284+
logger.debug("Expiring extra attributes for user %s", user_id)
1285+
del self._extra_attributes[user_id]
1286+
12291287
@staticmethod
12301288
def add_query_param_to_url(url: str, param_name: str, param: Any):
12311289
url_parts = list(urllib.parse.urlparse(url))

synapse/handlers/oidc_handler.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
from synapse.http.server import respond_with_html
3838
from synapse.http.site import SynapseRequest
3939
from synapse.logging.context import make_deferred_yieldable
40-
from synapse.types import UserID, map_username_to_mxid_localpart
40+
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
4141
from synapse.util import json_decoder
4242

4343
if TYPE_CHECKING:
@@ -707,14 +707,23 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
707707
self._render_error(request, "mapping_error", str(e))
708708
return
709709

710+
# Mapping providers might not have get_extra_attributes: only call this
711+
# method if it exists.
712+
extra_attributes = None
713+
get_extra_attributes = getattr(
714+
self._user_mapping_provider, "get_extra_attributes", None
715+
)
716+
if get_extra_attributes:
717+
extra_attributes = await get_extra_attributes(userinfo, token)
718+
710719
# and finally complete the login
711720
if ui_auth_session_id:
712721
await self._auth_handler.complete_sso_ui_auth(
713722
user_id, ui_auth_session_id, request
714723
)
715724
else:
716725
await self._auth_handler.complete_sso_login(
717-
user_id, request, client_redirect_url
726+
user_id, request, client_redirect_url, extra_attributes
718727
)
719728

720729
def _generate_oidc_session_token(
@@ -984,7 +993,7 @@ def get_remote_user_id(self, userinfo: UserInfo) -> str:
984993
async def map_user_attributes(
985994
self, userinfo: UserInfo, token: Token
986995
) -> UserAttribute:
987-
"""Map a ``UserInfo`` objects into user attributes.
996+
"""Map a `UserInfo` object into user attributes.
988997
989998
Args:
990999
userinfo: An object representing the user given by the OIDC provider
@@ -995,6 +1004,18 @@ async def map_user_attributes(
9951004
"""
9961005
raise NotImplementedError()
9971006

1007+
async def get_extra_attributes(self, userinfo: UserInfo, token: Token) -> JsonDict:
1008+
"""Map a `UserInfo` object into additional attributes passed to the client during login.
1009+
1010+
Args:
1011+
userinfo: An object representing the user given by the OIDC provider
1012+
token: A dict with the tokens returned by the provider
1013+
1014+
Returns:
1015+
A dict containing additional attributes. Must be JSON serializable.
1016+
"""
1017+
return {}
1018+
9981019

9991020
# Used to clear out "None" values in templates
10001021
def jinja_finalize(thing):
@@ -1009,6 +1030,7 @@ class JinjaOidcMappingConfig:
10091030
subject_claim = attr.ib() # type: str
10101031
localpart_template = attr.ib() # type: Template
10111032
display_name_template = attr.ib() # type: Optional[Template]
1033+
extra_attributes = attr.ib() # type: Dict[str, Template]
10121034

10131035

10141036
class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
@@ -1047,10 +1069,28 @@ def parse_config(config: dict) -> JinjaOidcMappingConfig:
10471069
% (e,)
10481070
)
10491071

1072+
extra_attributes = {} # type Dict[str, Template]
1073+
if "extra_attributes" in config:
1074+
extra_attributes_config = config.get("extra_attributes") or {}
1075+
if not isinstance(extra_attributes_config, dict):
1076+
raise ConfigError(
1077+
"oidc_config.user_mapping_provider.config.extra_attributes must be a dict"
1078+
)
1079+
1080+
for key, value in extra_attributes_config.items():
1081+
try:
1082+
extra_attributes[key] = env.from_string(value)
1083+
except Exception as e:
1084+
raise ConfigError(
1085+
"invalid jinja template for oidc_config.user_mapping_provider.config.extra_attributes.%s: %r"
1086+
% (key, e)
1087+
)
1088+
10501089
return JinjaOidcMappingConfig(
10511090
subject_claim=subject_claim,
10521091
localpart_template=localpart_template,
10531092
display_name_template=display_name_template,
1093+
extra_attributes=extra_attributes,
10541094
)
10551095

10561096
def get_remote_user_id(self, userinfo: UserInfo) -> str:
@@ -1071,3 +1111,13 @@ async def map_user_attributes(
10711111
display_name = None
10721112

10731113
return UserAttribute(localpart=localpart, display_name=display_name)
1114+
1115+
async def get_extra_attributes(self, userinfo: UserInfo, token: Token) -> JsonDict:
1116+
extras = {} # type: Dict[str, str]
1117+
for key, template in self._config.extra_attributes.items():
1118+
try:
1119+
extras[key] = template.render(user=userinfo).strip()
1120+
except Exception as e:
1121+
# Log an error and skip this value (don't break login for this).
1122+
logger.error("Failed to render OIDC extra attribute %s: %s" % (key, e))
1123+
return extras

synapse/rest/client/v1/login.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -284,9 +284,7 @@ async def _complete_login(
284284
self,
285285
user_id: str,
286286
login_submission: JsonDict,
287-
callback: Optional[
288-
Callable[[Dict[str, str]], Awaitable[Dict[str, str]]]
289-
] = None,
287+
callback: Optional[Callable[[Dict[str, str]], Awaitable[None]]] = None,
290288
create_non_existent_users: bool = False,
291289
) -> Dict[str, str]:
292290
"""Called when we've successfully authed the user and now need to
@@ -299,12 +297,12 @@ async def _complete_login(
299297
Args:
300298
user_id: ID of the user to register.
301299
login_submission: Dictionary of login information.
302-
callback: Callback function to run after registration.
300+
callback: Callback function to run after login.
303301
create_non_existent_users: Whether to create the user if they don't
304302
exist. Defaults to False.
305303
306304
Returns:
307-
result: Dictionary of account information after successful registration.
305+
result: Dictionary of account information after successful login.
308306
"""
309307

310308
# Before we actually log them in we check if they've already logged in
@@ -339,14 +337,24 @@ async def _complete_login(
339337
return result
340338

341339
async def _do_token_login(self, login_submission: JsonDict) -> Dict[str, str]:
340+
"""
341+
Handle the final stage of SSO login.
342+
343+
Args:
344+
login_submission: The JSON request body.
345+
346+
Returns:
347+
The body of the JSON response.
348+
"""
342349
token = login_submission["token"]
343350
auth_handler = self.auth_handler
344351
user_id = await auth_handler.validate_short_term_login_token_and_get_user_id(
345352
token
346353
)
347354

348-
result = await self._complete_login(user_id, login_submission)
349-
return result
355+
return await self._complete_login(
356+
user_id, login_submission, self.auth_handler._sso_login_callback
357+
)
350358

351359
async def _do_jwt_login(self, login_submission: JsonDict) -> Dict[str, str]:
352360
token = login_submission.get("token", None)

0 commit comments

Comments
 (0)