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
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
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 }}"



# Enable CAS for registration and login.
Expand Down
14 changes: 13 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,18 @@ 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
will be returned as part of the response during a successful login.

Note that care should be taken to not overwrite any of the parameters
usually returned as part of the [login response](https://matrix.org/docs/spec/client_server/latest#post-matrix-client-r0-login).

### Default OpenID Mapping Provider

Expand Down
16 changes: 16 additions & 0 deletions docs/workers.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,22 @@ for the room are in flight:

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

Additionally, the following endpoints should be included if Synapse is configured
to use SSO (you only need to include the ones for whichever SSO provider you're
using):

# OpenID Connect requests.
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect$
^/_synapse/oidc/callback$

# SAML requests.
^/_matrix/client/(api/v1|r0|unstable)/login/sso/redirect$
^/_matrix/saml2/authn_response$

# CAS requests.
^/_matrix/client/(api/v1|r0|unstable)/login/(cas|sso)/redirect$
^/_matrix/client/(api/v1|r0|unstable)/login/cas/ticket$

Note that a HTTP listener with `client` and `federation` resources must be
configured in the `worker_listeners` option in the worker config.

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 }}}}"
""".format(
mapping_provider=DEFAULT_USER_MAPPING_PROVIDER
)
60 changes: 59 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,37 @@ 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(extra_attributes.extra_attributes)

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
56 changes: 53 additions & 3 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,23 @@ 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.
extra_attributes = None
get_extra_attributes = getattr(
self._user_mapping_provider, "get_extra_attributes", None
)
if get_extra_attributes:
extra_attributes = await get_extra_attributes(userinfo, token)

# 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 @@ -984,7 +993,7 @@ def get_remote_user_id(self, userinfo: UserInfo) -> str:
async def map_user_attributes(
self, userinfo: UserInfo, token: Token
) -> UserAttribute:
"""Map a ``UserInfo`` objects into user attributes.
"""Map a `UserInfo` object into user attributes.

Args:
userinfo: An object representing the user given by the OIDC provider
Expand All @@ -995,6 +1004,18 @@ async def map_user_attributes(
"""
raise NotImplementedError()

async def get_extra_attributes(self, userinfo: UserInfo, token: Token) -> JsonDict:
"""Map a `UserInfo` object into additional attributes passed to the client during login.

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.
"""
return {}


# Used to clear out "None" values in templates
def jinja_finalize(thing):
Expand All @@ -1009,6 +1030,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 +1069,28 @@ def parse_config(config: dict) -> JinjaOidcMappingConfig:
% (e,)
)

extra_attributes = {} # type Dict[str, Template]
if "extra_attributes" in config:
extra_attributes_config = config.get("extra_attributes") or {}
if not isinstance(extra_attributes_config, dict):
raise ConfigError(
"oidc_config.user_mapping_provider.config.extra_attributes must be a dict"
)

for key, value in extra_attributes_config.items():
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)
)

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 +1111,13 @@ 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():
try:
extras[key] = template.render(user=userinfo).strip()
except Exception as e:
# Log an error and skip this value (don't break login for this).
logger.error("Failed to render OIDC extra attribute %s: %s" % (key, e))
return extras
22 changes: 15 additions & 7 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 All @@ -299,12 +297,12 @@ async def _complete_login(
Args:
user_id: ID of the user to register.
login_submission: Dictionary of login information.
callback: Callback function to run after registration.
callback: Callback function to run after login.
create_non_existent_users: Whether to create the user if they don't
exist. Defaults to False.

Returns:
result: Dictionary of account information after successful registration.
result: Dictionary of account information after successful login.
"""

# Before we actually log them in we check if they've already logged in
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