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

Allow additional SSO properties to be passed to the client #8413

Merged
merged 14 commits into from
Sep 30, 2020
Merged
1 change: 1 addition & 0 deletions changelog.d/8413.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support passing additional single sign-on parameters to the client.
8 changes: 8 additions & 0 deletions docs/sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1735,6 +1735,14 @@ oidc_config:
#
#display_name_template: "{{ user.given_name }} {{ user.last_name }}"

# Jinja2 templates for extra attributes to send back to the client during
# login.
#
# Note that these are non-standard and clients will ignore them without modifications.
#
#extra_attributes:
#birthdate: "{ user.birthdate }"
clokep marked this conversation as resolved.
Show resolved Hide resolved



# Enable CAS for registration and login.
Expand Down
12 changes: 11 additions & 1 deletion docs/sso_mapping_providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ A custom mapping provider must specify the following methods:
- This method must return a string, which is the unique identifier for the
user. Commonly the ``sub`` claim of the response.
* `map_user_attributes(self, userinfo, token)`
- This method should be async.
- This method must be async.
- Arguments:
- `userinfo` - A `authlib.oidc.core.claims.UserInfo` object to extract user
information from.
Expand All @@ -66,6 +66,16 @@ A custom mapping provider must specify the following methods:
- Returns a dictionary with two keys:
- localpart: A required string, used to generate the Matrix ID.
- displayname: An optional string, the display name for the user.
* `get_extra_attributes(self, userinfo, token)`
- This method must be async.
- Arguments:
- `userinfo` - A `authlib.oidc.core.claims.UserInfo` object to extract user
information from.
- `token` - A dictionary which includes information necessary to make
further requests to the OpenID provider.
- Returns a dictionary that is suitable to be serialized to JSON. This
dictionary will be returned under the `extra_attributes` key in the
response during a successful login.

### Default OpenID Mapping Provider

Expand Down
8 changes: 8 additions & 0 deletions synapse/config/oidc_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,14 @@ def generate_config_section(self, config_dir_path, server_name, **kwargs):
# If unset, no displayname will be set.
#
#display_name_template: "{{{{ user.given_name }}}} {{{{ user.last_name }}}}"

# Jinja2 templates for extra attributes to send back to the client during
# login.
#
# Note that these are non-standard and clients will ignore them without modifications.
#
#extra_attributes:
#birthdate: "{{ user.birthdate }}"
clokep marked this conversation as resolved.
Show resolved Hide resolved
clokep marked this conversation as resolved.
Show resolved Hide resolved
""".format(
mapping_provider=DEFAULT_USER_MAPPING_PROVIDER
)
62 changes: 61 additions & 1 deletion synapse/handlers/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,15 @@ def login_id_phone_to_thirdparty(identifier: JsonDict) -> Dict[str, str]:
}


@attr.s(slots=True)
class SsoLoginExtraAttributes:
"""Data we track about SAML2 sessions"""

# time the session was created, in milliseconds
creation_time = attr.ib(type=int)
extra_attributes = attr.ib(type=JsonDict)


class AuthHandler(BaseHandler):
SESSION_EXPIRE_MS = 48 * 60 * 60 * 1000

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

# A mapping of user ID to extra attributes to include in the login
# response.
self._extra_attributes = {} # type: Dict[str, SsoLoginExtraAttributes]

async def validate_user_via_ui_auth(
self,
requester: Requester,
Expand Down Expand Up @@ -1165,6 +1178,7 @@ async def complete_sso_login(
registered_user_id: str,
request: SynapseRequest,
client_redirect_url: str,
extra_attributes: Optional[JsonDict] = None,
):
"""Having figured out a mxid for this user, complete the HTTP request

Expand All @@ -1173,6 +1187,8 @@ async def complete_sso_login(
request: The request to complete.
client_redirect_url: The URL to which to redirect the user at the end of the
process.
extra_attributes: Extra attributes which will be passed to the client
during successful login. Must be JSON serializable.
"""
# If the account has been deactivated, do not proceed with the login
# flow.
Expand All @@ -1181,19 +1197,30 @@ async def complete_sso_login(
respond_with_html(request, 403, self._sso_account_deactivated_template)
return

self._complete_sso_login(registered_user_id, request, client_redirect_url)
self._complete_sso_login(
registered_user_id, request, client_redirect_url, extra_attributes
)

def _complete_sso_login(
self,
registered_user_id: str,
request: SynapseRequest,
client_redirect_url: str,
extra_attributes: Optional[JsonDict] = None,
):
"""
The synchronous portion of complete_sso_login.

This exists purely for backwards compatibility of synapse.module_api.ModuleApi.
"""
# Store any extra attributes which will be passed in the login response.
# Note that this is per-user so it may overwrite a previous value, this
# is considered OK since the newest SSO attributes should be most valid.
if extra_attributes:
self._extra_attributes[registered_user_id] = SsoLoginExtraAttributes(
self._clock.time_msec(), extra_attributes,
)
clokep marked this conversation as resolved.
Show resolved Hide resolved

# Create a login token
login_token = self.macaroon_gen.generate_short_term_login_token(
registered_user_id
Expand Down Expand Up @@ -1226,6 +1253,39 @@ def _complete_sso_login(
)
respond_with_html(request, 200, html)

async def _sso_login_callback(self, login_result: JsonDict) -> None:
"""
A login callback which might add additional attributes to the login response.

Args:
login_result: The data to be sent to the client. Includes the user
ID and access token.
"""
# Expire attributes before processing. Note that there shouldn't be any
# valid logins that still have extra attributes.
self._expire_sso_extra_attributes()

extra_attributes = self._extra_attributes.get(login_result["user_id"])
if extra_attributes:
login_result.update(
clokep marked this conversation as resolved.
Show resolved Hide resolved
(("extra_attributes", extra_attributes.extra_attributes),)
clokep marked this conversation as resolved.
Show resolved Hide resolved
)

def _expire_sso_extra_attributes(self) -> None:
"""
Iterate through the mapping of user IDs to extra attributes and remove any that are no longer valid.
"""
# TODO This should match the amount of time the macaroon is valid for.
LOGIN_TOKEN_EXPIRATION_TIME = 2 * 60 * 1000
expire_before = self._clock.time_msec() - LOGIN_TOKEN_EXPIRATION_TIME
to_expire = set()
for user_id, data in self._extra_attributes.items():
if data.creation_time < expire_before:
to_expire.add(user_id)
for user_id in to_expire:
logger.debug("Expiring extra attributes for user %s", user_id)
del self._extra_attributes[user_id]

@staticmethod
def add_query_param_to_url(url: str, param_name: str, param: Any):
url_parts = list(urllib.parse.urlparse(url))
Expand Down
47 changes: 45 additions & 2 deletions synapse/handlers/oidc_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
from synapse.http.server import respond_with_html
from synapse.http.site import SynapseRequest
from synapse.logging.context import make_deferred_yieldable
from synapse.types import UserID, map_username_to_mxid_localpart
from synapse.types import JsonDict, UserID, map_username_to_mxid_localpart
from synapse.util import json_decoder

if TYPE_CHECKING:
Expand Down Expand Up @@ -707,14 +707,26 @@ async def handle_oidc_callback(self, request: SynapseRequest) -> None:
self._render_error(request, "mapping_error", str(e))
return

# Mapping providers might not have get_extra_attributes: only call this
# method if it exists. It might also raise NotImplementedError.
extra_attributes = None
get_extra_attributes = getattr(
self._user_mapping_provider, "get_extra_attributes"
)
clokep marked this conversation as resolved.
Show resolved Hide resolved
if get_extra_attributes:
try:
extra_attributes = await get_extra_attributes(userinfo, token)
except NotImplementedError:
pass

# and finally complete the login
if ui_auth_session_id:
await self._auth_handler.complete_sso_ui_auth(
user_id, ui_auth_session_id, request
)
else:
await self._auth_handler.complete_sso_login(
user_id, request, client_redirect_url
user_id, request, client_redirect_url, extra_attributes
)

def _generate_oidc_session_token(
Expand Down Expand Up @@ -995,6 +1007,18 @@ async def map_user_attributes(
"""
raise NotImplementedError()

async def get_extra_attributes(self, userinfo: UserInfo, token: Token) -> JsonDict:
"""Map a ``UserInfo`` objects into additional attributes passed to the client during login.
clokep marked this conversation as resolved.
Show resolved Hide resolved

Args:
userinfo: An object representing the user given by the OIDC provider
token: A dict with the tokens returned by the provider

Returns:
A dict containing additional attributes. Must be JSON serializable.
"""
raise NotImplementedError()
clokep marked this conversation as resolved.
Show resolved Hide resolved


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


class JinjaOidcMappingProvider(OidcMappingProvider[JinjaOidcMappingConfig]):
Expand Down Expand Up @@ -1047,10 +1072,22 @@ def parse_config(config: dict) -> JinjaOidcMappingConfig:
% (e,)
)

extra_attributes = {} # type Dict[str, Template]
if "extra_attributes" in config:
for key, value in config["extra_attributes"].items():
clokep marked this conversation as resolved.
Show resolved Hide resolved
try:
extra_attributes[key] = env.from_string(value)
except Exception as e:
raise ConfigError(
"invalid jinja template for oidc_config.user_mapping_provider.config.extra_attributes.%s: %r"
% (key, e,)
clokep marked this conversation as resolved.
Show resolved Hide resolved
)

return JinjaOidcMappingConfig(
subject_claim=subject_claim,
localpart_template=localpart_template,
display_name_template=display_name_template,
extra_attributes=extra_attributes,
)

def get_remote_user_id(self, userinfo: UserInfo) -> str:
Expand All @@ -1071,3 +1108,9 @@ async def map_user_attributes(
display_name = None

return UserAttribute(localpart=localpart, display_name=display_name)

async def get_extra_attributes(self, userinfo: UserInfo, token: Token) -> JsonDict:
extras = {} # type: Dict[str, str]
for key, template in self._config.extra_attributes.items():
extras[key] = template.render(user=userinfo).strip()
clokep marked this conversation as resolved.
Show resolved Hide resolved
return extras
18 changes: 13 additions & 5 deletions synapse/rest/client/v1/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,9 +284,7 @@ async def _complete_login(
self,
user_id: str,
login_submission: JsonDict,
callback: Optional[
Callable[[Dict[str, str]], Awaitable[Dict[str, str]]]
] = None,
callback: Optional[Callable[[Dict[str, str]], Awaitable[None]]] = None,
clokep marked this conversation as resolved.
Show resolved Hide resolved
create_non_existent_users: bool = False,
) -> Dict[str, str]:
"""Called when we've successfully authed the user and now need to
Expand Down Expand Up @@ -339,14 +337,24 @@ async def _complete_login(
return result

async def _do_token_login(self, login_submission: JsonDict) -> Dict[str, str]:
"""
Handle the final stage of SSO login.

Args:
login_submission: The JSON request body.

Returns:
The body of the JSON response.
"""
token = login_submission["token"]
auth_handler = self.auth_handler
user_id = await auth_handler.validate_short_term_login_token_and_get_user_id(
token
)

result = await self._complete_login(user_id, login_submission)
return result
return await self._complete_login(
user_id, login_submission, self.auth_handler._sso_login_callback
)

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