From 8be4ac57cab536dbe039b72fd1fb239bbd18e99b Mon Sep 17 00:00:00 2001 From: Aditya Agarwal Date: Wed, 10 Jun 2026 21:19:16 +0200 Subject: [PATCH 1/2] fix: backdate JWT iat to avoid clock-skew 401s The server auth token stamped `iat = Time.now.to_i`. Because `iat` is a whole-second value (RFC 7519 NumericDate) and the server applies minimal forward leeway, a small fraction of requests were rejected with "token used before issue at (iat)" (HTTP 401) whenever the caller's clock was marginally ahead of the server and the second-truncation landed on a boundary. Observed at ~0.03% of requests, spread uniformly across all caller hosts. Backdate `iat` by Client::AUTH_IAT_LEEWAY_SECONDS (5s) so the token is always safely behind the server clock. The legacy stream-chat-ruby client never sent `iat`, which is why upgrades from it newly exposed this. Co-authored-by: Cursor --- CHANGELOG.md | 7 ++++++ lib/getstream_ruby/client.rb | 12 +++++++++- spec/auth_token_spec.rb | 45 ++++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 spec/auth_token_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ee2623..b2cef3a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,13 @@ ### Fixed +- Auth tokens now backdate the JWT `iat` claim by `Client::AUTH_IAT_LEEWAY_SECONDS` + (5s). `iat` is a whole-second value (RFC 7519 NumericDate) and the server applies + minimal forward leeway, so stamping `iat = Time.now.to_i` caused a small fraction of + requests to be rejected with `token used before issue at (iat)` (HTTP 401) whenever the + caller's clock was even marginally ahead of the server and the second-truncation landed + on a boundary. Backdating keeps the token safely behind the server clock. The legacy + `stream-chat-ruby` client never sent `iat`, so upgrades from it newly exposed this. - `event_class_for_type` now references `GetStream::Generated::Models::*Event` (was `StreamChat::*Event`, which raised `NameError` at runtime). `parse_event` resolves known event types correctly. diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index 4fc38c3..ad12204 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -271,10 +271,20 @@ def configure_adapter(connection) @configuration.effective_adapter = Faraday.default_adapter.to_s end + # Backdate the JWT `iat` claim by this many seconds. + # + # JWT timestamps are whole-second (RFC 7519 NumericDate), so `Time.now.to_i` + # truncates to the second. The server applies minimal forward leeway on + # `iat`, so stamping `iat = now` lets a small fraction of requests be + # rejected ("token used before issue at (iat)") whenever the caller's clock + # is even marginally ahead of the server and the truncation lands on a + # second boundary. Backdating absorbs that sub-second skew. + AUTH_IAT_LEEWAY_SECONDS = 5 + def generate_auth_header JWT.encode( { - iat: Time.now.to_i, + iat: Time.now.to_i - AUTH_IAT_LEEWAY_SECONDS, server: true, }, @configuration.api_secret, diff --git a/spec/auth_token_spec.rb b/spec/auth_token_spec.rb new file mode 100644 index 0000000..e502325 --- /dev/null +++ b/spec/auth_token_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'jwt' + +# Server auth token (JWT) generation. Guards against the clock-skew 401 +# regression: `iat` is backdated so a caller whose clock is marginally ahead of +# the server does not get intermittently rejected with +# "token used before issue at (iat)". +RSpec.describe 'server auth token' do + + let(:secret) { 's' } + let(:client) { GetStreamRuby.manual(api_key: 'k', api_secret: secret) } + + def decode_payload + header = client.send(:generate_auth_header) + JWT.decode(header, secret, true, algorithm: 'HS256').first + end + + it 'signs a server token with the server claim' do + + expect(decode_payload['server']).to be(true) + + end + + it 'backdates iat by AUTH_IAT_LEEWAY_SECONDS to absorb client/server clock skew' do + + before = Time.now.to_i + iat = decode_payload['iat'] + after = Time.now.to_i + + # iat must sit at least the leeway behind "now" at signing time, and never + # ahead of it, so the server never sees a future-dated token. + expect(iat).to be <= (before - GetStreamRuby::Client::AUTH_IAT_LEEWAY_SECONDS) + expect(iat).to be >= (after - GetStreamRuby::Client::AUTH_IAT_LEEWAY_SECONDS - 1) + + end + + it 'keeps the leeway positive so the backdate is actually applied' do + + expect(GetStreamRuby::Client::AUTH_IAT_LEEWAY_SECONDS).to be > 0 + + end + +end From 44cdb1fd90d4484944b3f7ab49bbfebab66105b1 Mon Sep 17 00:00:00 2001 From: Aditya Agarwal Date: Wed, 10 Jun 2026 21:22:46 +0200 Subject: [PATCH 2/2] style: move AUTH_IAT_LEEWAY_SECONDS out of private scope Fixes Lint/UselessConstantScoping: `private` does not affect constants, so the constant is declared at the top of the class instead. Co-authored-by: Cursor --- lib/getstream_ruby/client.rb | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/getstream_ruby/client.rb b/lib/getstream_ruby/client.rb index ad12204..26dd349 100644 --- a/lib/getstream_ruby/client.rb +++ b/lib/getstream_ruby/client.rb @@ -25,6 +25,16 @@ module GetStreamRuby class Client + # Backdate the JWT `iat` claim by this many seconds. + # + # JWT timestamps are whole-second (RFC 7519 NumericDate), so `Time.now.to_i` + # truncates to the second. The server applies minimal forward leeway on + # `iat`, so stamping `iat = now` lets a small fraction of requests be + # rejected ("token used before issue at (iat)") whenever the caller's clock + # is even marginally ahead of the server and the truncation lands on a + # second boundary. Backdating absorbs that sub-second skew. + AUTH_IAT_LEEWAY_SECONDS = 5 + attr_reader :configuration def initialize(config = nil, api_key: nil, api_secret: nil, **options) @@ -271,16 +281,6 @@ def configure_adapter(connection) @configuration.effective_adapter = Faraday.default_adapter.to_s end - # Backdate the JWT `iat` claim by this many seconds. - # - # JWT timestamps are whole-second (RFC 7519 NumericDate), so `Time.now.to_i` - # truncates to the second. The server applies minimal forward leeway on - # `iat`, so stamping `iat = now` lets a small fraction of requests be - # rejected ("token used before issue at (iat)") whenever the caller's clock - # is even marginally ahead of the server and the truncation lands on a - # second boundary. Backdating absorbs that sub-second skew. - AUTH_IAT_LEEWAY_SECONDS = 5 - def generate_auth_header JWT.encode( {