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

@@ -132,13 +132,41 @@ func DeviceDisplayName(stored *string, userAgent string) string {
// HandOverDeviceJTI moves a used device session from the jti of a session
// token to the jti of the token that refreshed it, and stamps its
// last_activity_at, so revoking the device still reaches the token the
// device holds now. POST /refresh calls it after a successful refresh;
// device holds now. It is FollowDeviceJTI without the revoked report;
// empty values do nothing.
func HandOverDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) error {
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
return nil
}
return db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI).Error
_, err := FollowDeviceJTI(ctx, db, oldJTI, newJTI)
return err
}
// FollowDeviceJTI moves a used device session from oldJTI, the jti of the
// session token a refresh presented, to newJTI, the jti of the token it
// minted, and stamps last_activity_at. It reports revoked when no used
// session moved but a session with oldJTI exists in another status: the
// device was revoked after the refresh passed the blacklist check, so the
// caller must refuse the refresh and drop the new token. The move is one
// guarded UPDATE that takes the session's row lock, as the revoke's
// UPDATE … RETURNING jwt_jti does (controllers.DeviceDestroy): a revoke
// that runs after the move blacklists newJTI, and a move that runs after
// the revoke matches nothing and reports revoked. POST /refresh calls it
// and fails closed on an error. Empty or equal values do nothing.
func FollowDeviceJTI(ctx context.Context, db *gorm.DB, oldJTI, newJTI string) (revoked bool, err error) {
if db == nil || oldJTI == "" || newJTI == "" || oldJTI == newJTI {
return false, nil
}
res := db.WithContext(ctx).Exec(`UPDATE golem15_user_device_auth_sessions
SET jwt_jti = ?, last_activity_at = NOW(), updated_at = NOW()
WHERE jwt_jti = ? AND status = 'used'`, newJTI, oldJTI)
if res.Error != nil {
return false, res.Error
}
if res.RowsAffected > 0 {
return false, nil
}
var stale int64
if err := db.WithContext(ctx).Raw(`SELECT COUNT(*) FROM golem15_user_device_auth_sessions
WHERE jwt_jti = ? AND status <> 'used'`, oldJTI).Scan(&stale).Error; err != nil {
return false, err
}
return stale > 0, nil
}