package clientapi_test import ( "net/http" "testing" ) const devicesPath = "/_matrix/client/v3/devices" func TestGetDevices(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") ts.login(acc, "login_device") ts.login(acc, "login_device_2") rec := ts.get(devicesPath, acc.AccessToken) expectStatus(t, rec, http.StatusOK) devices, _ := jsonBody(t, rec)["devices"].([]any) seen := map[string]bool{} for _, device := range devices { id, _ := device.(map[string]any)["device_id"].(string) seen[id] = true } for _, want := range []string{acc.DeviceID, "login_device", "login_device_2"} { if !seen[want] { t.Errorf("/devices did not return %s (got %v)", want, seen) } } } // TestGetDevicesIsPerUser is the check that matters most here: one user must // never see another's sessions. func TestGetDevicesIsPerUser(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "superuser") bob := ts.register("bob", "bobspassword") rec := ts.get(devicesPath, bob.AccessToken) expectStatus(t, rec, http.StatusOK) devices, _ := jsonBody(t, rec)["devices"].([]any) if len(devices) != 1 { t.Fatalf("bob sees %d devices, want 1", len(devices)) } if devices[0].(map[string]any)["device_id"] == alice.DeviceID { t.Fatal("bob can see alice's device") } } func TestGetDevice(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": acc.UserID}, "password": "superuser", "device_id": "login_device", "initial_device_display_name": "device display", }}) expectStatus(t, rec, http.StatusOK) rec = ts.get(devicesPath+"/login_device", acc.AccessToken) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) if body["device_id"] != "login_device" { t.Errorf("device_id = %v", body["device_id"]) } if body["display_name"] != "device display" { t.Errorf("display_name = %v, want %q", body["display_name"], "device display") } } func TestGetUnknownDeviceIs404(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") expectError(t, ts.get(devicesPath+"/unknown_device", acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND") } func TestPutDevice(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.do(request{method: http.MethodPut, path: devicesPath + "/" + acc.DeviceID, token: acc.AccessToken, body: map[string]any{"display_name": "new device display"}}) expectStatus(t, rec, http.StatusOK) rec = ts.get(devicesPath+"/"+acc.DeviceID, acc.AccessToken) if got := jsonBody(t, rec)["display_name"]; got != "new device display" { t.Errorf("display_name = %v, want the updated one", got) } } func TestPutUnknownDeviceIs404(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.do(request{method: http.MethodPut, path: devicesPath + "/unknown_device", token: acc.AccessToken, body: map[string]any{"display_name": "x"}}) expectError(t, rec, http.StatusNotFound, "M_NOT_FOUND") } // TestDeleteDeviceRequiresInteractiveAuth is the reason this endpoint is // interactive: a stolen access token must not be enough to log out the real // owner, which is exactly what they are trying to do when they reach for it. func TestDeleteDeviceRequiresInteractiveAuth(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") victim := ts.login(acc, "") path := devicesPath + "/" + victim.DeviceID // No body at all: the client does not know yet that it has to // authenticate, so this is the normal first request. rec := ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken}) expectStatus(t, rec, http.StatusUnauthorized) body := jsonBody(t, rec) for _, key := range []string{"session", "flows", "params"} { if _, present := body[key]; !present { t.Errorf("the challenge is missing %s: %s", key, rec.Body.String()) } } flows, _ := body["flows"].([]any) stages, _ := flows[0].(map[string]any)["stages"].([]any) if len(stages) != 1 || stages[0] != "m.login.password" { t.Fatalf("stages = %v, want [m.login.password]", stages) } session := body["session"] // The wrong password fails the stage and says so, keeping the session. rec = ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken, body: map[string]any{"auth": map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": acc.UserID}, "password": "super-wrong-password", "session": session, }}}) expectStatus(t, rec, http.StatusUnauthorized) body = jsonBody(t, rec) if body["errcode"] != "M_FORBIDDEN" { t.Errorf("errcode = %v, want M_FORBIDDEN: %s", body["errcode"], rec.Body.String()) } for _, key := range []string{"session", "flows", "params", "error"} { if _, present := body[key]; !present { t.Errorf("a failed stage is missing %s: %s", key, rec.Body.String()) } } // The device survived the failed attempt. expectStatus(t, ts.get(path, acc.AccessToken), http.StatusOK) // The right password completes it. rec = ts.do(request{method: http.MethodDelete, path: path, token: acc.AccessToken, body: map[string]any{"auth": map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": acc.UserID}, "password": "superuser", "session": session, }}}) expectStatus(t, rec, http.StatusOK) expectError(t, ts.get(path, acc.AccessToken), http.StatusNotFound, "M_NOT_FOUND") expectError(t, ts.get("/_matrix/client/v3/sync", victim.AccessToken), http.StatusUnauthorized, "M_UNKNOWN_TOKEN") } // TestDeleteDeviceRequiresTheOwnersPassword is the flaw the stage exists to // prevent: knowing *somebody's* password must not pass the // re-authentication on another account's session. func TestDeleteDeviceRequiresTheOwnersPassword(t *testing.T) { ts := newTestServer(t, nil) alice := ts.register("alice", "superuser") bob := ts.register("bob", "bobspassword") victim := ts.login(alice, "") path := devicesPath + "/" + victim.DeviceID rec := ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken}) session := jsonBody(t, rec)["session"] rec = ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken, body: map[string]any{"auth": map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": bob.UserID}, "password": "bobspassword", "session": session, }}}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") expectStatus(t, ts.get(path, alice.AccessToken), http.StatusOK) // Alice's own password still works. rec = ts.do(request{method: http.MethodDelete, path: path, token: alice.AccessToken, body: map[string]any{"auth": map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": alice.UserID}, "password": "superuser", "session": session, }}}) expectStatus(t, rec, http.StatusOK) expectError(t, ts.get(path, alice.AccessToken), http.StatusNotFound, "M_NOT_FOUND") } func TestDeleteDevices(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") first := ts.login(acc, "DEVICE_ONE") second := ts.login(acc, "DEVICE_TWO") const path = "/_matrix/client/v3/delete_devices" rec := ts.do(request{method: http.MethodPost, path: path, token: acc.AccessToken, body: map[string]any{"devices": []string{"DEVICE_ONE", "DEVICE_TWO"}}}) expectStatus(t, rec, http.StatusUnauthorized) session := jsonBody(t, rec)["session"] rec = ts.do(request{method: http.MethodPost, path: path, token: acc.AccessToken, body: map[string]any{ "devices": []string{"DEVICE_ONE", "DEVICE_TWO"}, "auth": map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": acc.UserID}, "password": "superuser", "session": session, }, }}) expectStatus(t, rec, http.StatusOK) for _, token := range []string{first.AccessToken, second.AccessToken} { expectError(t, ts.get("/_matrix/client/v3/sync", token), http.StatusUnauthorized, "M_UNKNOWN_TOKEN") } // The session doing the deleting is untouched. expectStatus(t, ts.get("/_matrix/client/v3/sync", acc.AccessToken), http.StatusOK) } // TestDeviceLastSeenIsRecorded covers the bookkeeping the device list shows. func TestDeviceLastSeenIsRecorded(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.get(devicesPath+"/"+acc.DeviceID, acc.AccessToken) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) if ts, _ := body["last_seen_ts"].(float64); ts <= 0 { t.Errorf("last_seen_ts = %v, want the time of the request that created it", body["last_seen_ts"]) } }