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

@@ -149,7 +149,7 @@ func DeviceInitiate(app *backpack.App) http.HandlerFunc {
"data": map[string]any{
"token": token,
"short_code": *row.ShortCode,
"auth_url": classes.DeviceAuthURL(app, token),
"auth_url": classes.DeviceAuthURLFor(app, token, *row.ShortCode),
"expires_at": wire.Time{Time: expires},
},
})
@@ -166,7 +166,10 @@ func DeviceInitiate(app *backpack.App) http.HandlerFunc {
// guarded confirmed-to-used UPDATE decides which poll that is, so of
// concurrent polls exactly one gets the token; the others, and every later
// poll, answer {"success":true,"data":{"status":"used"}}. Other statuses
// answer {"success":true,"data":{"status":…}}.
// answer {"success":true,"data":{"status":…}}. Only a poll with the
// User-Agent the device initiated with receives the token: another client
// that learned the device token answers {"status":"confirmed"} and takes
// nothing, so the device still collects its session.
func DeviceStatus(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
@@ -182,7 +185,7 @@ func DeviceStatus(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "Session not found"})
return
}
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) {
if row.Status == models.DeviceStatusConfirmed && row.UserID != nil && !row.ExpiredAt(time.Now()) && sameDeviceClient(r, row) {
if handOverDevice(w, r, app, gdb, secret, row) {
return
}
@@ -198,6 +201,17 @@ func DeviceStatus(app *backpack.App) http.HandlerFunc {
}
}
// sameDeviceClient reports whether r comes with the User-Agent the device
// initiated the session with (none for none), the binding of the token
// hand-over to the initiating client.
func sameDeviceClient(r *http.Request, row models.DeviceAuthSession) bool {
stored := ""
if row.DeviceUserAgent != nil {
stored = *row.DeviceUserAgent
}
return r.UserAgent() == stored
}
// deviceByToken loads the session whose token hash matches token.
func deviceByToken(r *http.Request, gdb *gorm.DB, token string) (models.DeviceAuthSession, bool, error) {
var row models.DeviceAuthSession
@@ -323,7 +337,9 @@ func authorizedBeforeCutoff(user *models.User, row models.DeviceAuthSession) boo
// DeviceAuthorize is POST /devices/authorize: a signed-in user (a valid,
// not blacklisted session token; no second-factor check, D-12c) binds a
// pending device session to themselves by its short_code (hyphens and
// spaces optional, any case) or by its token (the QR code). A short_code of
// spaces optional, any case) or by its token (the QR code page). The token
// field also takes the short code, which the default auth_url carries in
// place of the device token. A short_code of
// other than 8 characters after removing hyphens answers 422 {"error":
// "Please enter a valid 8-character code"}; an unknown, expired, used,
// revoked or foreign session answers 400 {"error":"Invalid or expired
@@ -371,10 +387,22 @@ func DeviceAuthorize(app *backpack.App) http.HandlerFunc {
}
found = res.RowsAffected == 1
} else if _, has := fields["token"]; has {
row, found, err = deviceByToken(r, gdb, strField(fields, "token"))
if err != nil {
writeOpaque500(w)
return
raw := strField(fields, "token")
if code, isCode := classes.NormalizeDeviceShortCode(raw); isCode {
// The QR page of the default auth_url carries the short
// code; a 64-character device token never normalizes.
res := gdb.WithContext(r.Context()).Where("short_code = ?", code).Limit(1).Find(&row)
if res.Error != nil {
writeOpaque500(w)
return
}
found = res.RowsAffected == 1
} else {
row, found, err = deviceByToken(r, gdb, raw)
if err != nil {
writeOpaque500(w)
return
}
}
}
if !found {