Repository: matrix-org/synapse

Issue:
Internal server error when trying to log in with OIDC when external ID is a number
[`get_user_by_external_id`](https://github.com/matrix-org/synapse/blob/bd6dc17221741d4ceae05ae769a70696ae939336/synapse/storage/data_stores/main/registration.py#L390-L408) retrieves an MXID from the ID sent by the authentication portal when using SSO.

In this function, we expect `external_id` to be a string, however we don't make sure the SSO portal returns with one. We should either add a check to both of the two places this function is called from to make sure the external ID is a string, or change that function to do that (e.g. if the ID isn't a string, try to cast it as such and error in a nicer way if it's not possible).

Sentry issue: https://sentry.matrix.org/sentry/synapse-modular/issues/116450/


Discussion:
As a sidenote, the main occurrence of that is if the localpart is a number, in which case we'll likely also run into other issues given Synapse reserves such localparts to guest users. It might not be an issue, but it would be nice to check when getting around to fixing this.
As a user trying to log in, I get this "Internal server error" page on the callback page.
![Screenshot_2020-07-07_11-33-24](https://user-images.githubusercontent.com/10872136/86760827-cf373680-c045-11ea-9eaa-c73443f23d98.png)

The synapse OpenID config I used on Modular was:

```
oidc_config:
   enabled: true
   discover: false
   issuer: "https://github.com/"
   client_id: "69…"
   client_secret: "60…"
   authorization_endpoint: "https://github.com/login/oauth/authorize"
   token_endpoint: "https://github.com/login/oauth/access_token"
   userinfo_endpoint: "https://api.github.com/user"
   scopes: ["read:user"]
   user_mapping_provider:
     config:
       subject_claim: "id"
       localpart_template: "{{ user.preferred_username }}"
       display_name_template: "{{ user.name }}"
```

The reproduced crash in Kibana, for those who have access:
https://modular-eucentral1-kibana.proxy.matrix.org/app/kibana#/doc/eb391830-aa29-11ea-be54-53b6e16b408b/filebeat-7.6.0-2020.07.07?id=y_n6KHMBxZr2sVMY-iPV&_g=(refreshInterval:(pause:!t,value:0),time:(from:'2020-07-07T11:13:13.852Z',to:'2020-07-07T11:13:14.000Z'))
> We should either add a check to both of the two places this function is called from to make sure the external ID is a string, or change that function to do that (e.g. if the ID isn't a string, try to cast it as such and error in a nicer way if it's not possible).

For what it's worth, I believe we expect the mapping providers to return strings, but we should probably assert this somewhere.
>  Internal server error when trying to log in with OIDC

I'm assuming there's more to it than that? Presumably this isn't *always* happening, otherwise the OIDC support would be useless?


> > Internal server error when trying to log in with OIDC
> 
> I'm assuming there's more to it than that? Presumably this isn't _always_ happening, otherwise the OIDC support would be useless?

My understanding is that this happens when the external ID is a number. I've updated the title.

Resolving patch:
diff --git a/synapse/handlers/oidc_handler.py b/synapse/handlers/oidc_handler.py
--- a/synapse/handlers/oidc_handler.py
+++ b/synapse/handlers/oidc_handler.py
@@ -869,6 +869,9 @@ async def _map_userinfo_to_user(
             raise MappingException(
                 "Failed to extract subject from OIDC response: %s" % (e,)
             )
+        # Some OIDC providers use integer IDs, but Synapse expects external IDs
+        # to be strings.
+        remote_user_id = str(remote_user_id)
 
         logger.info(
             "Looking for existing mapping for user %s:%s",


Test patch:
diff --git a/tests/handlers/test_oidc.py b/tests/handlers/test_oidc.py
--- a/tests/handlers/test_oidc.py
+++ b/tests/handlers/test_oidc.py
@@ -75,7 +75,17 @@ def deliverBody(self, protocol):
 COOKIE_NAME = b"oidc_session"
 COOKIE_PATH = "/_synapse/oidc"
 
-MockedMappingProvider = Mock(OidcMappingProvider)
+
+class TestMappingProvider(OidcMappingProvider):
+    @staticmethod
+    def parse_config(config):
+        return
+
+    def get_remote_user_id(self, userinfo):
+        return userinfo["sub"]
+
+    async def map_user_attributes(self, userinfo, token):
+        return {"localpart": userinfo["username"], "display_name": None}
 
 
 def simple_async_mock(return_value=None, raises=None):
@@ -123,7 +133,7 @@ def make_homeserver(self, reactor, clock):
         oidc_config["issuer"] = ISSUER
         oidc_config["scopes"] = SCOPES
         oidc_config["user_mapping_provider"] = {
-            "module": __name__ + ".MockedMappingProvider"
+            "module": __name__ + ".TestMappingProvider",
         }
         config["oidc_config"] = oidc_config
 
@@ -580,3 +590,30 @@ def test_exchange_code(self):
         with self.assertRaises(OidcError) as exc:
             yield defer.ensureDeferred(self.handler._exchange_code(code))
         self.assertEqual(exc.exception.error, "some_error")
+
+    def test_map_userinfo_to_user(self):
+        """Ensure that mapping the userinfo returned from a provider to an MXID works properly."""
+        userinfo = {
+            "sub": "test_user",
+            "username": "test_user",
+        }
+        # The token doesn't matter with the default user mapping provider.
+        token = {}
+        mxid = self.get_success(
+            self.handler._map_userinfo_to_user(
+                userinfo, token, "user-agent", "10.10.10.10"
+            )
+        )
+        self.assertEqual(mxid, "@test_user:test")
+
+        # Some providers return an integer ID.
+        userinfo = {
+            "sub": 1234,
+            "username": "test_user_2",
+        }
+        mxid = self.get_success(
+            self.handler._map_userinfo_to_user(
+                userinfo, token, "user-agent", "10.10.10.10"
+            )
+        )
+        self.assertEqual(mxid, "@test_user_2:test")