Files
Warp/server/internal/clientapi/login_test.go
T
tajniak81andClaude Opus 5 6004bb01de
CI / Test (ubuntu-latest) (push) Canceled after 0s
CI / Test (windows-latest) (push) Canceled after 0s
CI / Storage tests on PostgreSQL (push) Canceled after 0s
CI / Complement (federation off) (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
CI / Smoke test the binary (push) Canceled after 0s
Phase 1: accounts and sessions
Implements PLAN.md Phase 1. A client can now register, log in, manage its
devices and set its profile; an operator can create accounts and flip the
runtime switches. There are still no rooms and no real /sync.

userapi: accounts, argon2id passwords in PHC format, devices, access and
refresh tokens stored as SHA-256 hashes, account data and the admin flag.
Its storage interface is storage.UserStore, implemented once in sqlstore
with `?` placeholders; the postgres driver supplies a rebind function and
unique-violation recognition rather than keeping a second copy of thirty
statements. Timestamps in these tables are int64 milliseconds, the unit the
Client-Server API uses on the wire.

Client-Server API: register (user-interactive auth with m.login.dummy and
optional registration tokens), register/available, token validity, login
with m.login.password, logout, logout/all, refresh, account/whoami,
capabilities, devices/* with password-gated deletion, profile/*, global and
per-room account data.

GET /sync is a deliberate placeholder: it answers a valid token with an
empty but well-formed response and refuses a revoked one. Phase 1 has no
rooms, so that is the whole truth, and it is what makes session revocation
observable. Phase 4 replaces it with the syncapi.

Auth middleware resolves a bearer token (header or the legacy query
parameter) to a device and user, answering an expired token with the soft
logout the spec asks for. Rate limiting is a token bucket per endpoint class
keyed on the client address, returning M_LIMIT_EXCEEDED with retry_after_ms.

adminapi v1 under /_warp/admin/v1: admin-only auth via a normal Matrix
token, GET/PUT /federation and /registration driving internal/settings and
reporting a config-pinned switch as a 409, GET/POST /users, GET /stats.

warp create-account [--admin] creates the first account straight in the
database, so a new install with registration off has a way in.

Complement is wired up for the federation=off profile: an image meeting its
contract, an entrypoint that configures Warp entirely from WARP_*
variables, and a runner that builds, clones and executes the target set
(TestRegistration, TestLogin, TestDeviceManagement). It has not been run
yet -- there is no container runtime on the development machine -- so the
three tests' assertions were read from the Complement sources and pinned as
httptest tests instead, and CI gained a job that runs the real thing.

Also fixes a .gitignore bug from Phase 0: the unanchored `warp` pattern,
meant for the built binary, also matched the directory server/cmd/warp and
silently excluded the entire command package from the repository. The
binary patterns are now anchored to the repository root, and cmd/warp is
committed for the first time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 18:18:37 +02:00

526 lines
18 KiB
Go

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")
}
}
// TestSyncPlaceholder documents what the Phase 1 /sync does: it answers a
// valid token with an empty, well-formed response, and refuses a revoked
// one. Phase 4 replaces it with the real syncapi.
func TestSyncPlaceholder(t *testing.T) {
ts := newTestServer(t, nil)
acc := ts.register("alice", "superuser")
rec := ts.get("/_matrix/client/v3/sync", acc.AccessToken)
expectStatus(t, rec, http.StatusOK)
body := jsonBody(t, rec)
if next, _ := body["next_batch"].(string); next == "" {
t.Error("/sync must return a next_batch token")
}
rooms, _ := body["rooms"].(map[string]any)
for _, key := range []string{"join", "invite", "leave"} {
if _, present := rooms[key]; !present {
t.Errorf("rooms is missing %s: %s", key, rec.Body.String())
}
}
}
func TestUnknownClientEndpoint(t *testing.T) {
ts := newTestServer(t, nil)
rec := ts.get("/_matrix/client/v3/rooms/!a:b/messages", "")
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
}