fix: keep the device polling token off the QR code

golem15.user.device_auth.auth_url gains a {short_code} placeholder and
defaults to {frontend_url}/device/scan/{short_code}; POST
/devices/authorize accepts the short code in its token field, so a scan
page that posts what its URL carries keeps working. The status poll hands
the session token over only to a client with the User-Agent the device
initiated with; another client sees the session confirmed and takes
nothing.
This commit is contained in:
2026-10-26 16:12:08 +01:00
parent 557c03c595
commit 07196a4bcb
6 changed files with 142 additions and 24 deletions

View File

@@ -58,6 +58,28 @@ func TestDeviceAuthURL(t *testing.T) {
t.Fatalf("after %s=%v: auth_url = %q, want %q", step.key, step.value, got, step.want)
}
}
// DeviceAuthURLFor fills {short_code}; the default template carries the
// short code and never the device token (WR-01).
fresh := backpack.New(sessionConfig(t))
if err := fresh.Config.Set("golem15.user.frontend_url", "https://www.example.test"); err != nil {
t.Fatal(err)
}
for _, c := range []struct {
tpl string
want string
}{
{"", "https://www.example.test/device/scan/ABCD-EFGH"},
{"{frontend_url}/device/scan/{short_code}", "https://www.example.test/device/scan/ABCD-EFGH"},
{"{frontend_url}/pair?code={short_code}&t={token}", "https://www.example.test/pair?code=ABCD-EFGH&t=tok-1_A"},
{"{frontend_url}/device/scan/{token}", "https://www.example.test/device/scan/tok-1_A"},
} {
if err := fresh.Config.Set("golem15.user.device_auth.auth_url", c.tpl); err != nil {
t.Fatal(err)
}
if got := classes.DeviceAuthURLFor(fresh, "tok-1_A", "ABCD-EFGH"); got != c.want {
t.Fatalf("template %q: auth_url = %q, want %q", c.tpl, got, c.want)
}
}
for _, c := range []struct {
minutes any
want time.Duration
@@ -236,7 +258,8 @@ func TestDeviceInitiate(t *testing.T) {
if len(token) != 64 || !deviceShortCodeRE.MatchString(code) {
t.Fatalf("token %q, short code %q", token, code)
}
if data["auth_url"] != "https://app.example.test/device/scan/"+token {
// The QR code carries the short code, never the polling secret.
if data["auth_url"] != "https://app.example.test/device/scan/"+code || strings.Contains(data["auth_url"].(string), token) {
t.Fatalf("auth_url = %v", data["auth_url"])
}
expires, err := time.Parse("2006-01-02T15:04:05-07:00", data["expires_at"].(string))
@@ -572,6 +595,22 @@ func TestDeviceAuthorize(t *testing.T) {
wantBody(t, envAuthorize(t, env, session, map[string]string{"short_code": "ABC", "token": dev2.Token}), http.StatusUnprocessableEntity, deviceCodeLengthBody)
})
t.Run("token_field_takes_the_short_code", func(t *testing.T) {
// The default auth_url page carries the short code and posts it as
// token; any form NormalizeDeviceShortCode accepts works.
for _, form := range []func(string) string{
func(c string) string { return c },
func(c string) string { return strings.ToLower(strings.ReplaceAll(c, "-", "")) },
} {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": form(dev.ShortCode)}), http.StatusOK, deviceAuthorizedBody)
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusConfirmed {
t.Fatalf("status = %s", row.Status)
}
}
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": "ZZZZ-ZZZZ"}), http.StatusBadRequest, deviceInvalidBody)
})
t.Run("bound_to_another_user", func(t *testing.T) {
dev := envNewDevice(t, env)
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.Token}), http.StatusOK, deviceAuthorizedBody)
@@ -791,6 +830,39 @@ func TestDeviceHandOverConcurrent(t *testing.T) {
})
}
t.Run("only_the_initiating_client_collects_the_token", func(t *testing.T) {
const tvAgent = "Mozilla/5.0 (SMART-TV; Linux; Tizen 7.0)"
rec := env.send(t, http.MethodPost, "/_user/api/v1/devices/initiate", "", []byte(`{}`), "application/json", map[string]string{"User-Agent": tvAgent})
var started struct {
Data deviceInitiated `json:"data"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &started); err != nil || started.Data.Token == "" {
t.Fatalf("initiate %s: %v", rec.Body.String(), err)
}
dev := started.Data
wantBody(t, envAuthorize(t, env, session, map[string]string{"token": dev.ShortCode}), http.StatusOK, deviceAuthorizedBody)
before := events.count()
// An onlooker that learned the device token polls with another
// client: the session stays confirmed and no token leaves.
for _, agent := range []string{"", "curl/8.5.0", tvAgent + " "} {
rec := env.send(t, http.MethodGet, "/_user/api/v1/devices/status/"+dev.Token, "", nil, "application/json", map[string]string{"User-Agent": agent})
wantBody(t, rec, http.StatusOK, deviceStatusBody("confirmed"))
}
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusConfirmed || row.JWTJTI != nil {
t.Fatalf("a foreign poll changed the row: %+v", row)
}
if events.count() != before {
t.Fatal("a foreign poll fired a login event")
}
rec = env.send(t, http.MethodGet, "/_user/api/v1/devices/status/"+dev.Token, "", nil, "application/json", map[string]string{"User-Agent": tvAgent})
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"token":"`) {
t.Fatalf("the initiating client got no token: %d %s", rec.Code, rec.Body.String())
}
if row := envDeviceRow(t, env, dev.Token); row.Status != models.DeviceStatusUsed {
t.Fatalf("status = %s", row.Status)
}
})
t.Run("deleted_user_cascades", func(t *testing.T) {
user, userSession := envSession(t, env, "dho-gone")
dev := envNewDevice(t, env)