From 4ba5437267a82b5d212bd792871f42795bcf71c9 Mon Sep 17 00:00:00 2001 From: timedout Date: Wed, 15 Jul 2026 01:00:13 +0100 Subject: [PATCH 1/5] Perform UIA correctly when registering users on a deployment Fixes #893 Signed-off-by: timedout --- client/auth.go | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/client/auth.go b/client/auth.go index 8935ecb2..30be06ff 100644 --- a/client/auth.go +++ b/client/auth.go @@ -99,22 +99,39 @@ func (c *CSAPI) ConsumeRefreshToken(t ct.TestLike, refreshToken string) (newAcce } // RegisterUser will register the user with given parameters and -// return user ID, access token and device ID. It fails the test on network error. +// return user ID, access token and device ID. It fails the test on network error, +// or if registration fails for another reason (e.g. server has non-dummy requirements). func (c *CSAPI) RegisterUser(t ct.TestLike, localpart, password string) (userID, accessToken, deviceID string) { t.Helper() - reqBody := map[string]interface{}{ - "auth": map[string]string{ - "type": "m.login.dummy", - }, + reqBody := map[string]any{ "username": localpart, "password": password, } - res := c.MustDo(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) + // First request is expected to receive a UIA challenge + res := c.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) + if res.StatusCode != 401 { + ct.Fatalf(t, "Expected 401 Unauthorized, got %d", res.StatusCode) + } body, err := io.ReadAll(res.Body) if err != nil { ct.Fatalf(t, "unable to read response body: %v", err) } + session := GetJSONFieldStr(t, body, "session") + if session == "" { + ct.Fatalf(t, "uia challenge did not include a session") + } + + // Now actually register the user + reqBody["auth"] = map[string]any{ + "session": session, + "type": "m.login.dummy", + } + res = c.MustDo(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) + body, err = io.ReadAll(res.Body) + if err != nil { + ct.Fatalf(t, "unable to read response body: %v", err) + } userID = GetJSONFieldStr(t, body, "user_id") accessToken = GetJSONFieldStr(t, body, "access_token") From 96ac074fb1431decdb175e0cd60bf06cbce78037 Mon Sep 17 00:00:00 2001 From: timedout Date: Wed, 15 Jul 2026 01:45:58 +0100 Subject: [PATCH 2/5] Properly initiate UIA sessions in `TestRegistration` Signed-off-by: timedout --- tests/csapi/apidoc_register_test.go | 92 +++++++++++++++-------------- 1 file changed, 48 insertions(+), 44 deletions(-) diff --git a/tests/csapi/apidoc_register_test.go b/tests/csapi/apidoc_register_test.go index 965c0857..0f8a6e15 100644 --- a/tests/csapi/apidoc_register_test.go +++ b/tests/csapi/apidoc_register_test.go @@ -6,7 +6,9 @@ import ( "encoding/hex" "encoding/json" "fmt" + "io" "io/ioutil" + "maps" "net/http" "net/url" "testing" @@ -63,14 +65,10 @@ func TestRegistration(t *testing.T) { // sytest: POST /register can create a user t.Run("POST /register can create a user", func(t *testing.T) { t.Parallel() - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "post-can-create-a-user", - "password": "sUp3rs3kr1t" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "post-can-create-a-user", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 200, JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyTypeEqual("user_id", gjson.String), @@ -80,14 +78,10 @@ func TestRegistration(t *testing.T) { // sytest: POST /register downcases capitals in usernames t.Run("POST /register downcases capitals in usernames", func(t *testing.T) { t.Parallel() - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "user-UPPER", - "password": "sUp3rs3kr1t" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "user-UPPER", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 200, JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyEqual("user_id", "@user-upper:hs1"), @@ -98,15 +92,10 @@ func TestRegistration(t *testing.T) { t.Run("POST /register returns the same device_id as that in the request", func(t *testing.T) { t.Parallel() deviceID := "my_device_id" - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "user-device", - "password": "sUp3rs3kr1t", - "device_id": "`+deviceID+`" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "user-device", deviceID, map[string]any{"device_id": deviceID}) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 200, JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyEqual("device_id", deviceID), @@ -134,14 +123,10 @@ func TestRegistration(t *testing.T) { `'`, } for _, ch := range specialChars { - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, - client.WithJSONBody(t, map[string]interface{}{ - "auth": map[string]string{ - "type": "m.login.dummy", - }, - "username": "user-" + ch + "-reject-please", - "password": "sUp3rs3kr1t", - })) + // CONCERN: Should servers be expected to validate parameters before starting a UIA session? + // This test will flake if they do so, since 401 will be returned instead of 400. + reqBody, _ := startUIASession(t, unauthedClient, "user-"+ch+"-reject-please", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ StatusCode: 400, JSON: []match.JSON{ @@ -152,28 +137,21 @@ func TestRegistration(t *testing.T) { }) t.Run("POST /register rejects if user already exists", func(t *testing.T) { t.Parallel() - res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "post-can-create-a-user-once", - "password": "sUp3rs3kr1t" - }`))) + reqBody, _ := startUIASession(t, unauthedClient, "post-can-create-a-user-once", "sUp3rs3kr1t", nil) + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ JSON: []match.JSON{ match.JSONKeyTypeEqual("access_token", gjson.String), match.JSONKeyTypeEqual("user_id", gjson.String), }, }) - res = unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithRawBody(json.RawMessage(`{ - "auth": { - "type": "m.login.dummy" - }, - "username": "post-can-create-a-user-once", - "password": "anotherSuperSecret" - }`))) + reqBody, _ = startUIASession(t, unauthedClient, "post-can-create-a-user-once", "sUp3rs3kr1t", nil) + res = unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ StatusCode: 400, + JSON: []match.JSON{ + match.JSONKeyEqual("errcode", "M_USER_IN_USE"), + }, }) }) // sytest: POST /register allows registration of usernames with '$chr' @@ -346,3 +324,29 @@ func registerSharedSecret(t *testing.T, c *client.CSAPI, user, pass string, isAd resp = c.Do(t, "POST", []string{"_synapse", "admin", "v1", "register"}, client.WithJSONBody(t, reqBody)) return resp } + +// startUIASession starts a UIA session and returns the updated request body, +// and associated session token, failing the test if the response is not a UIA challenge. +func startUIASession(t *testing.T, c *client.CSAPI, user, pass string, extra map[string]any) (map[string]any, string) { + reqBody := map[string]any{ + "username": user, + "password": pass, + } + if extra != nil { + maps.Copy(extra, reqBody) + } + res := c.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) + if res.StatusCode != 401 { + t.Fatalf("expected status code 401 (UIA challenge), got %d", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatal(err) + } + session := client.GetJSONFieldStr(t, body, "session") + if session == "" { + t.Fatal("expected non-empty `session` in uia challenge") + } + reqBody["auth"] = map[string]string{"session": session, "type": "m.login.dummy"} + return reqBody, session +} From 42e5824dd2d11d9550c749ec331f4dd2e5ce6949 Mon Sep 17 00:00:00 2001 From: timedout Date: Wed, 15 Jul 2026 01:52:22 +0100 Subject: [PATCH 3/5] `session` is optional and may be omitted if not provided Signed-off-by: timedout --- client/auth.go | 6 +++--- tests/csapi/apidoc_register_test.go | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/client/auth.go b/client/auth.go index 30be06ff..13c629a8 100644 --- a/client/auth.go +++ b/client/auth.go @@ -118,15 +118,15 @@ func (c *CSAPI) RegisterUser(t ct.TestLike, localpart, password string) (userID, ct.Fatalf(t, "unable to read response body: %v", err) } session := GetJSONFieldStr(t, body, "session") - if session == "" { - ct.Fatalf(t, "uia challenge did not include a session") - } // Now actually register the user reqBody["auth"] = map[string]any{ "session": session, "type": "m.login.dummy", } + if session == "" { + delete(reqBody["auth"].(map[string]any), "session") + } res = c.MustDo(t, "POST", []string{"_matrix", "client", "v3", "register"}, WithJSONBody(t, reqBody)) body, err = io.ReadAll(res.Body) if err != nil { diff --git a/tests/csapi/apidoc_register_test.go b/tests/csapi/apidoc_register_test.go index 0f8a6e15..4dc16480 100644 --- a/tests/csapi/apidoc_register_test.go +++ b/tests/csapi/apidoc_register_test.go @@ -344,9 +344,9 @@ func startUIASession(t *testing.T, c *client.CSAPI, user, pass string, extra map t.Fatal(err) } session := client.GetJSONFieldStr(t, body, "session") + reqBody["auth"] = map[string]string{"session": session, "type": "m.login.dummy"} if session == "" { - t.Fatal("expected non-empty `session` in uia challenge") + delete(reqBody["auth"].(map[string]any), "session") } - reqBody["auth"] = map[string]string{"session": session, "type": "m.login.dummy"} return reqBody, session } From 6eea7dd3006d0d7d04746da98af2db918b61e508 Mon Sep 17 00:00:00 2001 From: timedout Date: Wed, 15 Jul 2026 02:00:32 +0100 Subject: [PATCH 4/5] Add a regression test to ensure `session` optionality is respected during registration Signed-off-by: timedout --- tests/csapi/apidoc_register_test.go | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/csapi/apidoc_register_test.go b/tests/csapi/apidoc_register_test.go index 4dc16480..6c161c34 100644 --- a/tests/csapi/apidoc_register_test.go +++ b/tests/csapi/apidoc_register_test.go @@ -286,6 +286,23 @@ func TestRegistration(t *testing.T) { }, }) }) + // Test that subsequent calls to /_matrix/client/v3/register after receiving a UIA + // challenge fail if the session is not provided. + t.Run("Registration without a session fails", func(t *testing.T) { + t.Parallel() + reqBody, session := startUIASession(t, unauthedClient, "auth-requires-session", "sUp3rs3kr1t", nil) + if session == "" { + t.Skip("Homeserver does not require a session for UIA") + } + delete(reqBody["auth"].(map[string]any), "session") + // Re-send the same request without the session. + // Since session is required if it is provided by the homeserver, this should + // return an error + res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) + must.MatchResponse(t, res, match.HTTPResponse{ + StatusCode: 401, + }) + }) }) } @@ -344,7 +361,7 @@ func startUIASession(t *testing.T, c *client.CSAPI, user, pass string, extra map t.Fatal(err) } session := client.GetJSONFieldStr(t, body, "session") - reqBody["auth"] = map[string]string{"session": session, "type": "m.login.dummy"} + reqBody["auth"] = map[string]any{"session": session, "type": "m.login.dummy"} if session == "" { delete(reqBody["auth"].(map[string]any), "session") } From e9e12a88c079cc62035e32bb5193dd2e8d4ebb54 Mon Sep 17 00:00:00 2001 From: timedout Date: Wed, 15 Jul 2026 02:06:29 +0100 Subject: [PATCH 5/5] Request validation is performed before authentication Also fixes the maps.Clone param order being inverted Signed-off-by: timedout --- tests/csapi/apidoc_register_test.go | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/csapi/apidoc_register_test.go b/tests/csapi/apidoc_register_test.go index 6c161c34..874f96c1 100644 --- a/tests/csapi/apidoc_register_test.go +++ b/tests/csapi/apidoc_register_test.go @@ -123,10 +123,13 @@ func TestRegistration(t *testing.T) { `'`, } for _, ch := range specialChars { - // CONCERN: Should servers be expected to validate parameters before starting a UIA session? - // This test will flake if they do so, since 401 will be returned instead of 400. - reqBody, _ := startUIASession(t, unauthedClient, "user-"+ch+"-reject-please", "sUp3rs3kr1t", nil) + reqBody := map[string]any{ + "username": "user-" + ch + "-reject-please", + "password": "sUp3rs3kr1t", + } res := unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) + // N.B. servers are expected to validate request bodies before handling UIA, + // so 400 is expected here, not 401. must.MatchResponse(t, res, match.HTTPResponse{ StatusCode: 400, JSON: []match.JSON{ @@ -145,7 +148,8 @@ func TestRegistration(t *testing.T) { match.JSONKeyTypeEqual("user_id", gjson.String), }, }) - reqBody, _ = startUIASession(t, unauthedClient, "post-can-create-a-user-once", "sUp3rs3kr1t", nil) + // Since servers validate the request body before handling auth, this returns 400 + delete(reqBody, "auth") res = unauthedClient.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) must.MatchResponse(t, res, match.HTTPResponse{ StatusCode: 400, @@ -350,7 +354,7 @@ func startUIASession(t *testing.T, c *client.CSAPI, user, pass string, extra map "password": pass, } if extra != nil { - maps.Copy(extra, reqBody) + maps.Copy(reqBody, extra) } res := c.Do(t, "POST", []string{"_matrix", "client", "v3", "register"}, client.WithJSONBody(t, reqBody)) if res.StatusCode != 401 {