Repository: matrix-org/synapse

Issue:
`/account/deactivate` is 500ing
### Description

Almost all requests to /_matrix/client/r0/account/deactivate on matrix.org failing with 500.

### Steps to reproduce

deactivate an account (might need to be social login); watch 500.

### Homeserver

matrix.org

### Synapse Version

1.65.0 (b=matrix-org-hotfixes,d20c92d2c2)

### Installation Method

_No response_

### Platform

debian

### Relevant log output

```shell
File "/home/synapse/src/synapse/http/server.py", line 368, in _async_render_wrapper
    callback_return = await self._async_render(request)
  File "/home/synapse/src/synapse/http/server.py", line 574, in _async_render
    callback_return = await raw_callback_return
  File "/home/synapse/src/synapse/rest/client/_base.py", line 99, in wrapped
    return await orig(*args, **kwargs)
  File "/home/synapse/src/synapse/rest/client/account.py", line 316, in on_POST
    await self.auth_handler.validate_user_via_ui_auth(
  File "/home/synapse/src/synapse/handlers/auth.py", line 344, in validate_user_via_ui_auth
    result, params, session_id = await self.check_ui_auth(
  File "/home/synapse/src/synapse/handlers/auth.py", line 470, in check_ui_auth
    if "session" in authdict:
TypeError: argument of type 'NoneType' is not iterable
```


### Anything else that would be useful to know?

_No response_


Discussion:
Looks like this broke on Wednesday morning; the first failing req was at 2022-08-17 09:57:26,485Z
Suspect my pydantic changes in #13188.
Ah. This should illustrate the root cause of the problem:

```python
>>> body = DeactivateAccountRestServlet.PostBody.parse_obj({})
>>> body.dict()
{'auth': None, 'id_server': None, 'erase': False}
```

This then causes the following lines a problem:
```python
authdict = clientdict.pop("auth", {})
if "session" in authdict:
```

If `clientdict` were an empty dict here, as it was before, then `authdict = {}`. But since Pydantic 'fills in' the missing fields with their default values (`None` here), then it will get `None` instead.
The real thing to blame is that the UI auth code is still using type unsafe `Dict[str, Any]`s everywhere, so this would never have been caught statically.

Resolving patch:
diff --git a/synapse/rest/client/account.py b/synapse/rest/client/account.py
--- a/synapse/rest/client/account.py
+++ b/synapse/rest/client/account.py
@@ -196,7 +196,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 params, session_id = await self.auth_handler.validate_user_via_ui_auth(
                     requester,
                     request,
-                    body.dict(),
+                    body.dict(exclude_unset=True),
                     "modify your account password",
                 )
             except InteractiveAuthIncompleteError as e:
@@ -219,7 +219,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
                 result, params, session_id = await self.auth_handler.check_ui_auth(
                     [[LoginType.EMAIL_IDENTITY]],
                     request,
-                    body.dict(),
+                    body.dict(exclude_unset=True),
                     "modify your account password",
                 )
             except InteractiveAuthIncompleteError as e:
@@ -316,7 +316,7 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
         await self.auth_handler.validate_user_via_ui_auth(
             requester,
             request,
-            body.dict(),
+            body.dict(exclude_unset=True),
             "deactivate your account",
         )
         result = await self._deactivate_account_handler.deactivate_account(


Test patch:
diff --git a/tests/handlers/test_deactivate_account.py b/tests/handlers/test_deactivate_account.py
--- a/tests/handlers/test_deactivate_account.py
+++ b/tests/handlers/test_deactivate_account.py
@@ -322,3 +322,18 @@ def test_account_data_preserved_by_background_update_if_not_deactivated(
                 )
             ),
         )
+
+    def test_deactivate_account_needs_auth(self) -> None:
+        """
+        Tests that making a request to /deactivate with an empty body
+        succeeds in starting the user-interactive auth flow.
+        """
+        req = self.make_request(
+            "POST",
+            "account/deactivate",
+            {},
+            access_token=self.token,
+        )
+
+        self.assertEqual(req.code, 401, req)
+        self.assertEqual(req.json_body["flows"], [{"stages": ["m.login.password"]}])