package user import ( "encoding/json" "net/http" "net/http/httptest" "regexp" "strconv" "strings" "sync" "testing" "time" "git.golem15.com/golem15/sm-user-plugin/classes" "git.golem15.com/golem15/sm-user-plugin/controllers" "git.golem15.com/golem15/sm-user-plugin/models" "git.golem15.com/golem15/summercms/modules/backpack" "git.golem15.com/golem15/summercms/modules/bouncer" "gorm.io/gorm" ) var deviceShortCodeRE = regexp.MustCompile(`^[2-9A-HJKMNP-Z]{4}-[2-9A-HJKMNP-Z]{4}$`) // TestDeviceCodesSmoke pins the device token, the short code, its // normalisation and the auth_url template. No database. func TestDeviceCodesSmoke(t *testing.T) { seen := map[string]bool{} for range 1000 { tok, err := classes.NewDeviceToken() if err != nil { t.Fatal(err) } if len(tok) != 64 || strings.Trim(tok, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_") != "" { t.Fatalf("token %q is not 64 base64url characters", tok) } if seen[tok] { t.Fatalf("token %q repeated", tok) } seen[tok] = true code, err := classes.NewDeviceShortCode() if err != nil { t.Fatal(err) } if !deviceShortCodeRE.MatchString(code) { t.Fatalf("short code %q", code) } } for in, want := range map[string]string{ "abcd-efgh": "ABCD-EFGH", "ABCDEFGH": "ABCD-EFGH", " ab cd ef gh ": "ABCD-EFGH", "2345-6789": "2345-6789", } { if got, ok := classes.NormalizeDeviceShortCode(in); !ok || got != want { t.Fatalf("normalize %q = %q %v, want %q", in, got, ok, want) } } for _, in := range []string{"ABCDEFG", "ABCDEFGHJ", "ABCDEFGO", "ABCDEFGI", "ABCDEFGL", "ABCDEFG0", "ABCDEFG1", ""} { if got, ok := classes.NormalizeDeviceShortCode(in); ok { t.Fatalf("normalize %q accepted as %q", in, got) } } app := backpack.New(sessionConfig(t)) if got := classes.DeviceAuthURL(app, "tok"); got != "/device/scan/tok" { t.Fatalf("auth_url without urls = %q", got) } if err := app.Config.Set("app.url", "https://api.example.test/"); err != nil { t.Fatal(err) } if got := classes.DeviceAuthURL(app, "tok"); got != "https://api.example.test/device/scan/tok" { t.Fatalf("auth_url from app.url = %q", got) } if err := app.Config.Set("golem15.user.frontend_url", "https://www.example.test/"); err != nil { t.Fatal(err) } if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/device/scan/tok" { t.Fatalf("auth_url from frontend_url = %q", got) } if err := app.Config.Set("golem15.user.device_auth.auth_url", "{frontend_url}/link?device={token}"); err != nil { t.Fatal(err) } if got := classes.DeviceAuthURL(app, "tok"); got != "https://www.example.test/link?device=tok" { t.Fatalf("auth_url from a custom template = %q", got) } if got := classes.DeviceAuthTTL(app); got != 5*time.Minute { t.Fatalf("default ttl = %s", got) } tv := "Living room TV" for _, c := range []struct { stored *string ua string want string }{ {&tv, "Mozilla/5.0 (iPhone)", tv}, {nil, "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0)", "iPhone"}, {nil, "Mozilla/5.0 (iPad; CPU OS 17_0)", "iPad"}, {nil, "Mozilla/5.0 (Linux; Android 14)", "Android Device"}, {nil, "Mozilla/5.0 (Windows NT 10.0)", "Windows PC"}, {nil, "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)", "Mac"}, {nil, "Mozilla/5.0 (X11; Linux x86_64)", "Linux PC"}, {nil, "curl/8.0", "Unknown Device"}, } { if got := classes.DeviceDisplayName(c.stored, c.ua); got != c.want { t.Fatalf("display name of %q = %q, want %q", c.ua, got, c.want) } } } // deviceCall runs one device handler with an optional bearer token and JSON // body; pathToken fills {token} or {id}. func deviceCall(t *testing.T, h http.HandlerFunc, method, path, bearerToken, body string, pathValues map[string]string) *httptest.ResponseRecorder { t.Helper() var req *http.Request if body != "" { req = httptest.NewRequest(method, path, strings.NewReader(body)) req.Header.Set("Content-Type", "application/json") } else { req = httptest.NewRequest(method, path, nil) } req.Host = "example.com" req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) SmokeTV") if bearerToken != "" { req.Header.Set("Authorization", "Bearer "+bearerToken) } for k, v := range pathValues { req.SetPathValue(k, v) } rec := httptest.NewRecorder() h(rec, req) return rec } type deviceInitiated struct { Token string `json:"token"` ShortCode string `json:"short_code"` AuthURL string `json:"auth_url"` ExpiresAt string `json:"expires_at"` } func initiateDevice(t *testing.T, app *backpack.App, name string) deviceInitiated { t.Helper() body := `{}` if name != "" { body = `{"device_name":"` + name + `"}` } rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", body, nil) if rec.Code != http.StatusOK { t.Fatalf("initiate = %d %s", rec.Code, rec.Body.String()) } var out struct { Success bool `json:"success"` Data deviceInitiated `json:"data"` } if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || !out.Success { t.Fatalf("initiate body %s: %v", rec.Body.String(), err) } return out.Data } func deviceStatus(t *testing.T, app *backpack.App, token string) (int, map[string]any) { t.Helper() rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+token, "", "", map[string]string{"token": token}) var body map[string]any _ = json.Unmarshal(rec.Body.Bytes(), &body) return rec.Code, body } func authorizeDevice(t *testing.T, app *backpack.App, bearerToken, body string) *httptest.ResponseRecorder { t.Helper() return deviceCall(t, controllers.DeviceAuthorize(app), http.MethodPost, "/_user/api/v1/devices/authorize", bearerToken, body, nil) } func deviceRow(t *testing.T, gdb *gorm.DB, token string) models.DeviceAuthSession { t.Helper() var row models.DeviceAuthSession if err := gdb.Where("token = ?", classes.HashAPIToken(token)).Take(&row).Error; err != nil { t.Fatal(err) } return row } func statusData(body map[string]any) map[string]any { data, _ := body["data"].(map[string]any) return data } // TestDeviceFlowSmoke drives initiate, authorize and the one-time hand-over // on Postgres. func TestDeviceFlowSmoke(t *testing.T) { if testing.Short() { t.Skip("Postgres (testcontainers)") } app, gdb := sessionApp(t, "0s") email := "device-owner-" + strconv.FormatInt(time.Now().UnixNano(), 10) + "@example.test" session := loginToken(t, app, gdb, email) var owner models.User if err := gdb.Where("email = ?", email).Take(&owner).Error; err != nil { t.Fatal(err) } dev := initiateDevice(t, app, "Living room TV") if len(dev.Token) != 64 || !deviceShortCodeRE.MatchString(dev.ShortCode) || !strings.Contains(dev.AuthURL, dev.Token) || !strings.HasSuffix(dev.ExpiresAt, "+00:00") { t.Fatalf("initiate data = %+v", dev) } row := deviceRow(t, gdb, dev.Token) if row.TokenHash != classes.HashAPIToken(dev.Token) || row.Status != models.DeviceStatusPending || row.UserID != nil || row.DeviceName == nil || *row.DeviceName != "Living room TV" || row.DeviceUserAgent == nil { t.Fatalf("stored row = %+v", row) } if code, body := deviceStatus(t, app, dev.Token); code != http.StatusOK || body["success"] != true || statusData(body)["status"] != "pending" { t.Fatalf("pending status = %d %v", code, body) } if code, body := deviceStatus(t, app, strings.Repeat("z", 64)); code != http.StatusNotFound || body["error"] != "Session not found" { t.Fatalf("unknown status = %d %v", code, body) } // Authorize needs a session, answering PHP's 401 body. if rec := authorizeDevice(t, app, "", `{"short_code":"`+dev.ShortCode+`"}`); rec.Code != http.StatusUnauthorized || rec.Body.String() != `{"error":"Unauthorized"}` { t.Fatalf("authorize without a session = %d %s", rec.Code, rec.Body.String()) } if rec := authorizeDevice(t, app, session, `{"short_code":"ABC"}`); rec.Code != http.StatusUnprocessableEntity || rec.Body.String() != `{"error":"Please enter a valid 8-character code"}` { t.Fatalf("short code of 3 = %d %s", rec.Code, rec.Body.String()) } lower := strings.ToLower(dev.ShortCode) if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK || rec.Body.String() != `{"message":"Device authorized successfully","success":true}` { t.Fatalf("authorize = %d %s", rec.Code, rec.Body.String()) } if rec := authorizeDevice(t, app, session, `{"short_code":"`+lower+`"}`); rec.Code != http.StatusOK { t.Fatalf("authorizing again for the same user = %d %s", rec.Code, rec.Body.String()) } // The first poll after confirmation receives the token, once. code, body := deviceStatus(t, app, dev.Token) data := statusData(body) if code != http.StatusOK || data["status"] != "confirmed" || data["message"] != "Device authorized - complete login" || data["user_id"] != float64(owner.ID) { t.Fatalf("hand-over = %d %v", code, body) } jwtToken, _ := data["token"].(string) sub, _, _, jti, err := bouncer.VerifyClaims(jwtToken, sessionSecret) if err != nil || sub != strconv.FormatUint(uint64(owner.ID), 10) { t.Fatalf("device token sub=%q err=%v", sub, err) } if user, _ := data["user"].(map[string]any); user["email"] != email { t.Fatalf("hand-over user = %v", data["user"]) } row = deviceRow(t, gdb, dev.Token) if row.Status != models.DeviceStatusUsed || row.JWTJTI == nil || *row.JWTJTI != jti || row.UsedAt == nil { t.Fatalf("row after hand-over = %+v", row) } if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" || statusData(body)["token"] != nil { t.Fatalf("second poll = %v", body) } // The used session is past nothing: even past expires_at it stays used. if err := gdb.Model(&models.DeviceAuthSession{}).Where("id = ?", row.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error; err != nil { t.Fatal(err) } if _, body := deviceStatus(t, app, dev.Token); statusData(body)["status"] != "used" { t.Fatalf("used session past expires_at = %v", body) } // A used session cannot be authorized again. if rec := authorizeDevice(t, app, session, `{"token":"`+dev.Token+`"}`); rec.Code != http.StatusBadRequest || rec.Body.String() != `{"error":"Invalid or expired code"}` { t.Fatalf("authorize a used session = %d %s", rec.Code, rec.Body.String()) } // Ten concurrent polls of a confirmed session yield exactly one token. race := initiateDevice(t, app, "") if rec := authorizeDevice(t, app, session, `{"token":"`+race.Token+`"}`); rec.Code != http.StatusOK { t.Fatalf("authorize by token = %d %s", rec.Code, rec.Body.String()) } var ( wg sync.WaitGroup mu sync.Mutex tokens int used int ) for range 10 { wg.Add(1) go func() { defer wg.Done() _, body := deviceStatus(t, app, race.Token) d := statusData(body) mu.Lock() defer mu.Unlock() if tok, _ := d["token"].(string); tok != "" { tokens++ } else if d["status"] == "used" { used++ } }() } wg.Wait() if tokens != 1 || used != 9 { t.Fatalf("concurrent polls: %d tokens, %d used", tokens, used) } // A pending session past expires_at answers PHP's flat expired body. stale := initiateDevice(t, app, "") if err := gdb.Model(&models.DeviceAuthSession{}).Where("token = ?", classes.HashAPIToken(stale.Token)).Update("expires_at", time.Now().Add(-time.Minute)).Error; err != nil { t.Fatal(err) } if rec := authorizeDevice(t, app, session, `{"short_code":"`+stale.ShortCode+`"}`); rec.Code != http.StatusBadRequest { t.Fatalf("authorize an expired session = %d %s", rec.Code, rec.Body.String()) } rec := deviceCall(t, controllers.DeviceStatus(app), http.MethodGet, "/_user/api/v1/devices/status/"+stale.Token, "", "", map[string]string{"token": stale.Token}) if rec.Body.String() != `{"message":"Authorization session has expired","status":"expired","success":true}` { t.Fatalf("expired status = %d %s", rec.Code, rec.Body.String()) } if row := deviceRow(t, gdb, stale.Token); row.Status != models.DeviceStatusExpired { t.Fatalf("expired row status = %s", row.Status) } // device_name is bounded. long := strings.Repeat("n", 256) if rec := deviceCall(t, controllers.DeviceInitiate(app), http.MethodPost, "/_user/api/v1/devices/initiate", "", `{"device_name":"`+long+`"}`, nil); rec.Code != http.StatusUnprocessableEntity { t.Fatalf("device_name of 256 = %d %s", rec.Code, rec.Body.String()) } }