fix: a device revoke cannot lose to a concurrent refresh

The revoke now reads the device's jti with UPDATE ... RETURNING under the
row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a
session revoked meanwhile, so the refresh answers the blacklisted-token
401 without a token. A hand-over error fails the refresh closed instead
of leaving the session on a stale jti.
This commit is contained in:
2026-10-26 16:00:43 +01:00
parent 1f3266a417
commit cc3636cea9
5 changed files with 187 additions and 45 deletions

View File

@@ -1038,6 +1038,88 @@ func TestDeviceRevokeAfterRefresh(t *testing.T) {
})
}
// TestDeviceRevokeRacesRefresh pins that a revoke and a refresh of the same
// device cannot leave the device signed in, whichever commits first. A
// refresh whose device session was revoked after the token passed the
// blacklist check (the revoke committed between bouncer.Refresh and the
// hand-over) is refused with the blacklisted-token answer and hands out no
// token; a revoke reads the jti under the row lock the hand-over takes, so
// a device that refreshes in a tight loop while it is revoked always ends
// with its last token refused.
func TestDeviceRevokeRacesRefresh(t *testing.T) {
if testing.Short() {
t.Skip("Postgres")
}
env := newAdminEnv(t)
const blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
t.Run("revoked_after_the_blacklist_check", func(t *testing.T) {
_, session := envSession(t, env, "drace-check")
token, jti, id := envDevice(t, env, session, "Race desk")
// The state a revoke leaves when it commits after bouncer.Refresh
// checked the blacklist: the row is revoked and the token the
// refresh presented is not blacklisted (yet).
envSetDevice(t, env, id, map[string]any{"status": models.DeviceStatusRevoked})
wantBody(t, env.call(t, http.MethodPost, "/_user/api/v1/refresh", token, nil), http.StatusUnauthorized, blacklistedBody)
var row models.DeviceAuthSession
if err := env.gdb.Where("id = ?", id).Take(&row).Error; err != nil {
t.Fatal(err)
}
if row.JWTJTI == nil || *row.JWTJTI != jti || row.Status != models.DeviceStatusRevoked {
t.Fatalf("a refused refresh moved the revoked row: %+v", row)
}
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", token, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("the presented token still works: %d", rec.Code)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
})
t.Run("refresh_loop_and_revoke", func(t *testing.T) {
for round := range 8 {
// A user per round keeps the refreshes inside the per-user
// user-api bucket.
_, session := envSession(t, env, "drace-loop-"+strconv.Itoa(round))
token, _, id := envDevice(t, env, session, "Loop desk")
var (
wg sync.WaitGroup
last = token
status int
)
wg.Add(1)
go func() {
defer wg.Done()
for range 100 {
rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil)
if status = rec.Code; status != http.StatusOK {
return
}
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body.Token == "" {
t.Errorf("refresh %s: %v", rec.Body.String(), err)
return
}
last = body.Token
}
}()
time.Sleep(time.Duration(round) * 2 * time.Millisecond)
wantBody(t, env.call(t, http.MethodDelete, "/_user/api/v1/devices/"+strconv.FormatUint(uint64(id), 10), session, nil), http.StatusOK, deviceRevokedOKBody)
wg.Wait()
if status != http.StatusUnauthorized {
t.Fatalf("round %d: the refresh loop ended with %d, want the 401 of a revoked device", round, status)
}
if rec := env.call(t, http.MethodGet, "/_user/api/v1/fetch", last, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("round %d: the device's last token still works after the revoke: %d", round, rec.Code)
}
if rec := env.call(t, http.MethodPost, "/_user/api/v1/refresh", last, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("round %d: the device's last token still refreshes after the revoke: %d", round, rec.Code)
}
env.expect(t, http.StatusOK, http.MethodGet, "/_user/api/v1/fetch", session, nil)
}
})
}
// TestDeviceTokensAtRest pins D-06 for device sessions: the token column is
// the SHA-256 hex of the token, never the token; no column holds the token
// or the session JWT the device received; the model hides token,