package clientapi_test import ( "net/http" "net/http/httptest" "testing" "time" "github.com/dariuszjaniak/warp/server/internal/clientapi" "github.com/dariuszjaniak/warp/server/internal/config" ) const loginPath = "/_matrix/client/v3/login" func TestLoginFlows(t *testing.T) { ts := newTestServer(t, nil) rec := ts.get(loginPath, "") expectStatus(t, rec, http.StatusOK) if ct := rec.Header().Get("Content-Type"); ct != "application/json" { t.Errorf("Content-Type = %q, want application/json", ct) } flows, _ := jsonBody(t, rec)["flows"].([]any) if len(flows) == 0 { t.Fatal("flows must not be empty") } // Clients read the flat `type`; a strict reading of the spec wants // `stages`. Both are sent, so both are checked. for _, flow := range flows { entry, _ := flow.(map[string]any) if entry["type"] != "m.login.password" { t.Errorf("flow type = %v, want m.login.password", entry["type"]) } if stages, ok := entry["stages"].([]any); !ok || len(stages) == 0 { t.Errorf("flow %v has no stages", entry) } } } func TestLoginWithPassword(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", }}) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) for _, field := range []string{"user_id", "access_token", "device_id"} { if value, _ := body[field].(string); value == "" { t.Errorf("login response is missing %s: %s", field, rec.Body.String()) } } if body["user_id"] != acc.UserID { t.Errorf("user_id = %v, want %s", body["user_id"], acc.UserID) } // Logging in must not reuse the registration's device, or a second // client would silently take over the first one's session. if body["device_id"] == acc.DeviceID { t.Error("a login without a device_id must create a new device") } } // TestLoginAcceptsEveryIdentifierForm covers the three shapes clients send: // the full user ID, the bare localpart, and the deprecated top-level `user`. func TestLoginAcceptsEveryIdentifierForm(t *testing.T) { ts := newTestServer(t, nil) ts.register("alice", "superuser") for name, body := range map[string]map[string]any{ "full user ID": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "@alice:warp.example"}, "password": "superuser", }, "localpart only": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "alice"}, "password": "superuser", }, "deprecated user field": { "type": "m.login.password", "user": "alice", "password": "superuser", }, "uppercase user ID": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "@ALICE:warp.example"}, "password": "superuser", }, "uppercase server name": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "@alice:WARP.example"}, "password": "superuser", }, } { t.Run(name, func(t *testing.T) { rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body}) expectStatus(t, rec, http.StatusOK) if got := jsonBody(t, rec)["user_id"]; got != "@alice:warp.example" { t.Errorf("user_id = %v, want @alice:warp.example", got) } }) } } func TestLoginHonoursRequestedDeviceID(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": "test_device_id", "initial_device_display_name": "device display", }}) expectStatus(t, rec, http.StatusOK) if got := jsonBody(t, rec)["device_id"]; got != "test_device_id" { t.Errorf("device_id = %v, want test_device_id", got) } } // TestLoginOnExistingDeviceRevokesItsToken is the spec's rule for a login // naming a device that already exists: the device stays, its old token does // not. func TestLoginOnExistingDeviceRevokesItsToken(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") first := ts.login(acc, "SHARED") second := ts.login(acc, "SHARED") if first.AccessToken == second.AccessToken { t.Fatal("the second login returned the same token") } expectError(t, ts.get("/_matrix/client/v3/account/whoami", first.AccessToken), http.StatusUnauthorized, "M_UNKNOWN_TOKEN") expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", second.AccessToken), http.StatusOK) } // TestLoginRejectionsAreIndistinguishable keeps the login endpoint from // becoming a user directory: every failure is the same answer. func TestLoginRejectionsAreIndistinguishable(t *testing.T) { ts := newTestServer(t, nil) ts.register("alice", "superuser") for name, body := range map[string]map[string]any{ "wrong password": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "alice"}, "password": "wrong_password", }, "unknown user": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "i-dont-exist"}, "password": "superuser", }, "malformed user ID": { "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "@not-a-user-id"}, "password": "superuser", }, } { t.Run(name, func(t *testing.T) { rec := ts.do(request{method: http.MethodPost, path: loginPath, body: body}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") }) } } func TestLoginRejectsForeignUser(t *testing.T) { ts := newTestServer(t, nil) 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": "@bob:other.example"}, "password": "superuser", }}) expectError(t, rec, http.StatusForbidden, "M_FORBIDDEN") } func TestLoginRejectsUnsupportedType(t *testing.T) { ts := newTestServer(t, nil) rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{ "type": "m.login.token", "token": "whatever", }}) expectError(t, rec, http.StatusBadRequest, "M_UNKNOWN") } func TestLoginRequiresAnIdentifier(t *testing.T) { ts := newTestServer(t, nil) rec := ts.do(request{method: http.MethodPost, path: loginPath, body: map[string]any{ "type": "m.login.password", "password": "superuser", }}) expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM") } func TestLogout(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") second := ts.login(acc, "") rec := ts.get("/_matrix/client/v3/devices", acc.AccessToken) expectStatus(t, rec, http.StatusOK) if devices, _ := jsonBody(t, rec)["devices"].([]any); len(devices) != 2 { t.Fatalf("expected 2 devices before logging out, got %d", len(devices)) } rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: second.AccessToken}) expectStatus(t, rec, http.StatusOK) // The token is dead and the device is gone, not merely signed out. expectError(t, ts.get("/_matrix/client/v3/sync", second.AccessToken), http.StatusUnauthorized, "M_UNKNOWN_TOKEN") rec = ts.get("/_matrix/client/v3/devices", acc.AccessToken) expectStatus(t, rec, http.StatusOK) devices, _ := jsonBody(t, rec)["devices"].([]any) if len(devices) != 1 { t.Fatalf("expected 1 device after logging out, got %d", len(devices)) } if devices[0].(map[string]any)["device_id"] != acc.DeviceID { t.Error("the wrong device was removed") } } func TestLogoutAll(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") second := ts.login(acc, "") rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout/all", token: second.AccessToken}) expectStatus(t, rec, http.StatusOK) for name, token := range map[string]string{"the caller": second.AccessToken, "the other session": acc.AccessToken} { t.Run(name, func(t *testing.T) { expectError(t, ts.get("/_matrix/client/v3/sync", token), http.StatusUnauthorized, "M_UNKNOWN_TOKEN") }) } } func TestLogoutRequiresAToken(t *testing.T) { ts := newTestServer(t, nil) rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout"}) expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN") rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/logout", token: "invalidAccessToken"}) expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN") } // TestRefreshToken covers the rotation: a refresh swaps both halves of the // pair, and the spent ones stop working. func TestRefreshToken(t *testing.T) { ts := newTestServer(t, nil) 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": "alice"}, "password": "superuser", "refresh_token": true, }}) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) oldAccess, _ := body["access_token"].(string) refresh, _ := body["refresh_token"].(string) if refresh == "" { t.Fatalf("refresh_token: true produced no refresh token: %s", rec.Body.String()) } rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh", body: map[string]any{"refresh_token": refresh}}) expectStatus(t, rec, http.StatusOK) refreshed := jsonBody(t, rec) newAccess, _ := refreshed["access_token"].(string) newRefresh, _ := refreshed["refresh_token"].(string) if newAccess == "" || newRefresh == "" || newRefresh == refresh { t.Fatalf("refresh did not rotate the pair: %s", rec.Body.String()) } expectStatus(t, ts.get("/_matrix/client/v3/account/whoami", newAccess), http.StatusOK) expectError(t, ts.get("/_matrix/client/v3/account/whoami", oldAccess), http.StatusUnauthorized, "M_UNKNOWN_TOKEN") // Replaying a spent refresh token is a soft logout, not a second // rotation. rec = ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh", body: map[string]any{"refresh_token": refresh}}) expectError(t, rec, http.StatusUnauthorized, "M_UNKNOWN_TOKEN") if jsonBody(t, rec)["soft_logout"] != true { t.Error("a dead refresh token should be reported as a soft logout") } } func TestRefreshRequiresAToken(t *testing.T) { ts := newTestServer(t, nil) rec := ts.do(request{method: http.MethodPost, path: "/_matrix/client/v3/refresh", body: map[string]any{}}) expectError(t, rec, http.StatusBadRequest, "M_MISSING_PARAM") } // TestLoginWithoutRefreshTokenOmitsIt keeps the response honest for the // clients that do not support rotation. func TestLoginWithoutRefreshTokenOmitsIt(t *testing.T) { ts := newTestServer(t, nil) 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": "alice"}, "password": "superuser", }}) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) if _, present := body["refresh_token"]; present { t.Error("a login that did not ask for a refresh token must not get one") } if _, present := body["expires_in_ms"]; present { t.Error("a token that does not expire must not report a lifetime") } } func TestLoginIsRateLimited(t *testing.T) { ts := newTestServer(t, withRateLimit(config.RateLimit{ Enabled: true, Default: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond}, Login: config.RateLimitBucket{Burst: 2, Interval: time.Minute}, Register: config.RateLimitBucket{Burst: 100, Interval: time.Millisecond}, })) attempt := func() int { return ts.do(request{ method: http.MethodPost, path: loginPath, remote: "192.0.2.10:4321", body: map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "alice"}, "password": "wrong", }, }).Code } for i := range 2 { if status := attempt(); status != http.StatusForbidden { t.Fatalf("attempt %d status = %d, want 403", i+1, status) } } rec := ts.do(request{ method: http.MethodPost, path: loginPath, remote: "192.0.2.10:4321", body: map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "alice"}, "password": "wrong", }, }) expectError(t, rec, http.StatusTooManyRequests, "M_LIMIT_EXCEEDED") retryAfter, _ := jsonBody(t, rec)["retry_after_ms"].(float64) if retryAfter <= 0 { t.Errorf("retry_after_ms = %v, want a positive number", retryAfter) } // Another address has its own budget: one noisy client must not lock // everybody out. ts2 := ts.do(request{ method: http.MethodPost, path: loginPath, remote: "198.51.100.7:4321", body: map[string]any{ "type": "m.login.password", "identifier": map[string]any{"type": "m.id.user", "user": "alice"}, "password": "wrong", }, }) if ts2.Code == http.StatusTooManyRequests { t.Error("the limiter is keyed globally rather than per client") } // And the budget is per endpoint class, so /login running dry does not // take the rest of the API with it. if rec := ts.get("/_matrix/client/v3/login", ""); rec.Code != http.StatusOK { t.Errorf("GET /login status = %d; limiters must be per endpoint", rec.Code) } } // TestAccessTokenInQueryString covers the form older clients and media URLs // still use. func TestAccessTokenInQueryString(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.get("/_matrix/client/v3/account/whoami?access_token="+acc.AccessToken, "") expectStatus(t, rec, http.StatusOK) if jsonBody(t, rec)["user_id"] != acc.UserID { t.Errorf("whoami = %s", rec.Body.String()) } } func TestMalformedAuthorizationHeaderIsRejected(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") req := request{method: http.MethodGet, path: "/_matrix/client/v3/account/whoami"} for name, header := range map[string]string{ "no scheme": acc.AccessToken, "wrong scheme": "Basic " + acc.AccessToken, "empty bearer": "Bearer ", } { t.Run(name, func(t *testing.T) { httpReq := requestWithRawAuth(t, req, header) rec := serve(ts, httpReq) expectError(t, rec, http.StatusUnauthorized, "M_MISSING_TOKEN") }) } // The scheme itself is case-insensitive, per RFC 9110. rec := serve(ts, requestWithRawAuth(t, req, "bearer "+acc.AccessToken)) expectStatus(t, rec, http.StatusOK) } func TestWhoami(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") rec := ts.get("/_matrix/client/v3/account/whoami", acc.AccessToken) expectStatus(t, rec, http.StatusOK) body := jsonBody(t, rec) if body["user_id"] != acc.UserID || body["device_id"] != acc.DeviceID { t.Errorf("whoami = %s", rec.Body.String()) } if body["is_guest"] != false { t.Errorf("is_guest = %v, want false", body["is_guest"]) } } func TestCapabilities(t *testing.T) { ts := newTestServer(t, nil) acc := ts.register("alice", "superuser") // The endpoint is authenticated: what a server lets you do depends on // who you are. expectError(t, ts.get("/_matrix/client/v3/capabilities", ""), http.StatusUnauthorized, "M_MISSING_TOKEN") rec := ts.get("/_matrix/client/v3/capabilities", acc.AccessToken) expectStatus(t, rec, http.StatusOK) caps, _ := jsonBody(t, rec)["capabilities"].(map[string]any) for _, key := range []string{"m.room_versions", "m.change_password", "m.set_displayname"} { if _, present := caps[key]; !present { t.Errorf("capabilities is missing %s: %s", key, rec.Body.String()) } } versions, _ := caps["m.room_versions"].(map[string]any) if versions["default"] != clientapi.DefaultRoomVersion { t.Errorf("default room version = %v, want %s", versions["default"], clientapi.DefaultRoomVersion) } if available, _ := versions["available"].(map[string]any); len(available) == 0 { t.Error("m.room_versions.available must list at least the default") } } func TestUnknownClientEndpoint(t *testing.T) { ts := newTestServer(t, nil) // A real endpoint this build does not serve: third-party protocols // belong to the application service API of PLAN.md phase 9. The spec's // answer for an endpoint a server does not implement is // M_UNRECOGNIZED, and the router's catch-all is what gives it. rec := ts.get("/_matrix/client/v3/thirdparty/protocols", "") expectError(t, rec, http.StatusNotFound, "M_UNRECOGNIZED") } // serve runs a prepared request through the handler. func serve(ts *testServer, req *http.Request) *httptest.ResponseRecorder { rec := httptest.NewRecorder() ts.handler.ServeHTTP(rec, req) return rec } // requestWithRawAuth builds a request carrying an Authorization header // verbatim, so the parsing of the header itself can be tested. func requestWithRawAuth(t *testing.T, req request, header string) *http.Request { t.Helper() httpReq := httptest.NewRequest(req.method, req.path, nil) httpReq.Header.Set("Authorization", header) return httpReq }