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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user