Repository: matrix-org/synapse

Issue:
Consent flow skipped during SSO user registration if username is left at default
<!--

**THIS IS NOT A SUPPORT CHANNEL!**
**IF YOU HAVE SUPPORT QUESTIONS ABOUT RUNNING OR CONFIGURING YOUR OWN HOME SERVER**,
please ask in **#synapse:matrix.org** (using a matrix.org account if necessary)

If you want to report a security issue, please see https://matrix.org/security-disclosure-policy/

This is a bug report template. By following the instructions below and
filling out the sections with your information, you will help the us to get all
the necessary data to fix your issue.

You can also preview your report before submitting it. You may remove sections
that aren't relevant to your particular case.

Text between <!-- and --​> marks will be invisible in the report.

-->

### Description

When using an SSO mapping provider that automatically sets a new user's mxid localpart (as opposed to prompting the user to choose it), then [requiring consent tracking on new user registration](https://github.com/matrix-org/synapse/blob/develop/docs/consent_tracking.md#enabling-consent-tracking-at-registration) doesn't work: registering a new user via SSO does not prompt for consent & lets the registration finish as if consent was not required.

### Steps to reproduce

- Follow the steps to [enable the user consent flow](https://github.com/matrix-org/synapse/blob/develop/docs/consent_tracking.md#collecting-policy-agreement-from-a-user) and to [make it mandatory on new user registration](https://github.com/matrix-org/synapse/blob/develop/docs/consent_tracking.md#enabling-consent-tracking-at-registration).
- Configure a SSO mapping provider that returns a mxid localpart
- Register a new user via SSO

<!--
Describe how what happens differs from what you expected.

If you can identify any relevant log snippets from _homeserver.log_, please include
those (please be careful to remove any personal or private data). Please surround them with
``` (three backticks, on a line on their own), so that they are formatted legibly.
-->

Expected results: The registration flow should eventually open a webpage at `/_matrix/consent` and not register a new user until the consent form on that page has been submitted.

Actual results: The registration will complete without prompting for consent, as if `user_consent.require_at_registration` was not set. Other consent configurations still take affect, though, such as `user_consent.block_events_error`.

### Version information

<!-- IMPORTANT: please answer the following questions, to help us narrow down the problem -->

<!-- Was this issue identified on matrix.org or another homeserver? -->
- **Homeserver**: Self-hosted

If not matrix.org:

<!--
 What version of Synapse is running?

You can find the Synapse version with this command:

$ curl http://localhost:8008/_synapse/admin/v1/server_version

(You may need to replace `localhost:8008` if Synapse is not configured to
listen on that port.)
-->
- **Version**: 1.41.1

- **Install method**: git clone
<!-- examples: package manager/git clone/pip  -->

- **Platform**: Fedora 34
<!--
Tell us about the environment in which your homeserver is operating
distro, hardware, if it's running in a vm/container, etc.
-->



Discussion:

Resolving patch:
diff --git a/synapse/handlers/sso.py b/synapse/handlers/sso.py
--- a/synapse/handlers/sso.py
+++ b/synapse/handlers/sso.py
@@ -449,14 +449,16 @@ async def complete_sso_login_request(
             if not user_id:
                 attributes = await self._call_attribute_mapper(sso_to_matrix_id_mapper)
 
-                if attributes.localpart is None:
-                    # the mapper doesn't return a username. bail out with a redirect to
-                    # the username picker.
-                    await self._redirect_to_username_picker(
+                next_step_url = self._get_url_for_next_new_user_step(
+                    attributes=attributes
+                )
+                if next_step_url:
+                    await self._redirect_to_next_new_user_step(
                         auth_provider_id,
                         remote_user_id,
                         attributes,
                         client_redirect_url,
+                        next_step_url,
                         extra_login_attributes,
                     )
 
@@ -535,18 +537,53 @@ async def _call_attribute_mapper(
             )
         return attributes
 
-    async def _redirect_to_username_picker(
+    def _get_url_for_next_new_user_step(
+        self,
+        attributes: Optional[UserAttributes] = None,
+        session: Optional[UsernameMappingSession] = None,
+    ) -> bytes:
+        """Returns the URL to redirect to for the next step of new user registration
+
+        Given attributes from the user mapping provider or a UsernameMappingSession,
+        returns the URL to redirect to for the next step of the registration flow.
+
+        Args:
+            attributes: the user attributes returned by the user mapping provider,
+                from before a UsernameMappingSession has begun.
+
+            session: an active UsernameMappingSession, possibly with some of its
+                attributes chosen by the user.
+
+        Returns:
+            The URL to redirect to, or an empty value if no redirect is necessary
+        """
+        # Must provide either attributes or session, not both
+        assert (attributes is not None) != (session is not None)
+
+        if (attributes and attributes.localpart is None) or (
+            session and session.chosen_localpart is None
+        ):
+            return b"/_synapse/client/pick_username/account_details"
+        elif self._consent_at_registration and not (
+            session and session.terms_accepted_version
+        ):
+            return b"/_synapse/client/new_user_consent"
+        else:
+            return b"/_synapse/client/sso_register" if session else b""
+
+    async def _redirect_to_next_new_user_step(
         self,
         auth_provider_id: str,
         remote_user_id: str,
         attributes: UserAttributes,
         client_redirect_url: str,
+        next_step_url: bytes,
         extra_login_attributes: Optional[JsonDict],
     ) -> NoReturn:
         """Creates a UsernameMappingSession and redirects the browser
 
-        Called if the user mapping provider doesn't return a localpart for a new user.
-        Raises a RedirectException which redirects the browser to the username picker.
+        Called if the user mapping provider doesn't return complete information for a new user.
+        Raises a RedirectException which redirects the browser to a specified URL.
 
         Args:
             auth_provider_id: A unique identifier for this SSO provider, e.g.
@@ -559,12 +596,15 @@ async def _redirect_to_username_picker(
             client_redirect_url: The redirect URL passed in by the client, which we
                 will eventually redirect back to.
 
+            next_step_url: The URL to redirect to for the next step of the new user flow.
+
             extra_login_attributes: An optional dictionary of extra
                 attributes to be provided to the client in the login response.
 
         Raises:
             RedirectException
         """
+        # TODO: If needed, allow using/looking up an existing session here.
         session_id = random_string(16)
         now = self._clock.time_msec()
         session = UsernameMappingSession(
@@ -575,13 +615,18 @@ async def _redirect_to_username_picker(
             client_redirect_url=client_redirect_url,
             expiry_time_ms=now + self._MAPPING_SESSION_VALIDITY_PERIOD_MS,
             extra_login_attributes=extra_login_attributes,
+            # Treat the localpart returned by the user mapping provider as though
+            # it was chosen by the user. If it's None, it must be chosen eventually.
+            chosen_localpart=attributes.localpart,
+            # TODO: Consider letting the user mapping provider specify defaults for
+            #       other user-chosen attributes.
         )
 
         self._username_mapping_sessions[session_id] = session
         logger.info("Recorded registration session id %s", session_id)
 
-        # Set the cookie and redirect to the username picker
-        e = RedirectException(b"/_synapse/client/pick_username/account_details")
+        # Set the cookie and redirect to the next step
+        e = RedirectException(next_step_url)
         e.cookies.append(
             b"%s=%s; path=/"
             % (USERNAME_MAPPING_SESSION_COOKIE_NAME, session_id.encode("ascii"))
@@ -810,16 +855,9 @@ async def handle_submit_username_request(
                 )
         session.emails_to_use = filtered_emails
 
-        # we may now need to collect consent from the user, in which case, redirect
-        # to the consent-extraction-unit
-        if self._consent_at_registration:
-            redirect_url = b"/_synapse/client/new_user_consent"
-
-        # otherwise, redirect to the completion page
-        else:
-            redirect_url = b"/_synapse/client/sso_register"
-
-        respond_with_redirect(request, redirect_url)
+        respond_with_redirect(
+            request, self._get_url_for_next_new_user_step(session=session)
+        )
 
     async def handle_terms_accepted(
         self, request: Request, session_id: str, terms_version: str
@@ -847,8 +885,9 @@ async def handle_terms_accepted(
 
         session.terms_accepted_version = terms_version
 
-        # we're done; now we can register the user
-        respond_with_redirect(request, b"/_synapse/client/sso_register")
+        respond_with_redirect(
+            request, self._get_url_for_next_new_user_step(session=session)
+        )
 
     async def register_sso_user(self, request: Request, session_id: str) -> None:
         """Called once we have all the info we need to register a new user.
diff --git a/synapse/rest/synapse/client/new_user_consent.py b/synapse/rest/synapse/client/new_user_consent.py
--- a/synapse/rest/synapse/client/new_user_consent.py
+++ b/synapse/rest/synapse/client/new_user_consent.py
@@ -63,8 +63,8 @@ async def _async_render_GET(self, request: Request) -> None:
             self._sso_handler.render_error(request, "bad_session", e.msg, code=e.code)
             return
 
-        # It should be impossible to get here without having first been through
-        # the pick-a-username step, which ensures chosen_localpart gets set.
+        # It should be impossible to get here without either the user or the mapping provider
+        # having chosen a username, which ensures chosen_localpart gets set.
         if not session.chosen_localpart:
             logger.warning("Session has no user name selected")
             self._sso_handler.render_error(


Test patch: