workos 8.0.0 → 8.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 974591cf2aee743c7ea0f4c00e9d851bcd83275e7425f6b57913f1d3ee3674d8
4
- data.tar.gz: f7170a52d3dbf70cfc7a2abb05d8017812e8c96c759d7d414034cb3e3b8f0b19
3
+ metadata.gz: 24ec60a2134602d268907b2a885e04872b8ad5bb7ccff1095abb1a1d7bbb30d7
4
+ data.tar.gz: 1ef26bc5f186054f6949156b127d64ad3b1533c38468e0c919105b2ce7e0dd4c
5
5
  SHA512:
6
- metadata.gz: 9ecabceabdc1847645a1a07fa7d747bfde8bdf8fe1cb86c487d44b5b2d0a87c49e41bb32d49c8511009970bd3de64ac13b6674616a3b8026ae10bccfd337a81b
7
- data.tar.gz: 41d43875ebc5c9de7f87b8ce3e810795846c6077ae79c8f752fdd8894c6b5de574bd5e594d3a2a6a77ef7853b94ee04fe2d811b52cf69576bebcd6812fab9c70
6
+ metadata.gz: 81b868876a7b9541e043f8ed407b8a0ef1879df9d96ce1163ea737ea037132c80a036a03636165f53e0c96e9a0a52bdc6a8775fc84e953df1be9aa8646e78e34
7
+ data.tar.gz: 9118c02daa8efe8812fb999fd1ac745bc1760f7e6a0613fe34f4272489eff27ebe552e653b2b7dab36de54b8ef06919a81936873af8741e2c7f57e9b4f71898c
@@ -1,7 +1,7 @@
1
1
  name: Publish API Docs
2
2
  on:
3
3
  push:
4
- tags: ['v*']
4
+ branches: [main]
5
5
  workflow_dispatch:
6
6
  permissions:
7
7
  contents: read
@@ -37,6 +37,9 @@ jobs:
37
37
  if-no-files-found: error
38
38
  deploy:
39
39
  needs: build
40
+ permissions:
41
+ pages: write
42
+ id-token: write
40
43
  environment:
41
44
  name: github-pages
42
45
  url: ${{ steps.deployment.outputs.page_url }}
@@ -3,7 +3,7 @@ name: Release Please
3
3
  on:
4
4
  push:
5
5
  branches:
6
- - main
6
+ - v8.0.x
7
7
 
8
8
  permissions:
9
9
  contents: write
@@ -24,6 +24,7 @@ jobs:
24
24
  id: release
25
25
  with:
26
26
  token: ${{ steps.generate-token.outputs.token }}
27
+ target-branch: v8.0.x
27
28
 
28
29
  - name: Update Gemfile.lock on release PR
29
30
  if: steps.release.outputs.pr
@@ -45,8 +46,8 @@ jobs:
45
46
  if git diff --quiet Gemfile.lock; then
46
47
  echo "Gemfile.lock is up to date"
47
48
  else
48
- git config user.name "github-actions[bot]"
49
- git config user.email "github-actions[bot]@users.noreply.github.com"
49
+ git config user.name "workos-sdk-automation[bot]"
50
+ git config user.email "255426317+workos-sdk-automation[bot]@users.noreply.github.com"
50
51
  git add Gemfile.lock
51
52
  git commit -m "chore: update Gemfile.lock"
52
53
  git push
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "8.0.0"
2
+ ".": "8.0.2"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [8.0.2](https://github.com/workos/workos-ruby/compare/v8.0.1...v8.0.2) (2026-08-20)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * explicitly evict connections if request does not complete as success ([d79b839](https://github.com/workos/workos-ruby/commit/d79b8390690ce595896d1b6057cf68e5058b98da))
9
+
10
+ ## [8.0.1](https://github.com/workos/workos-ruby/compare/v8.0.0...v8.0.1) (2026-05-12)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * harden session sealing, log redaction, and webhook tolerance checks ([#482](https://github.com/workos/workos-ruby/issues/482)) ([347fe1e](https://github.com/workos/workos-ruby/commit/347fe1edf296778d7ea331e666a7957870074b9f))
16
+
3
17
  ## [8.0.0](https://github.com/workos/workos-ruby/compare/v7.1.2...v8.0.0) (2026-05-06)
4
18
 
5
19
 
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- workos (8.0.0)
4
+ workos (8.0.2)
5
5
  jwt (~> 3.1)
6
6
  logger (~> 1.7)
7
7
  zeitwerk (~> 2.6)
@@ -153,7 +153,7 @@ CHECKSUMS
153
153
  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
154
154
  webmock (3.26.2) sha256=774556f2ea6371846cca68c01769b2eac0d134492d21f6d0ab5dd643965a4c90
155
155
  webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131
156
- workos (8.0.0)
156
+ workos (8.0.2)
157
157
  yard (0.9.43) sha256=cf8733a8f0485df2a162927e9b5f182215a61f6d22de096b8f402c726a1c5821
158
158
  yard-markdown (0.7.1) sha256=06c378632dfe7ba053be9ba469eb4701aa0470e36bcf7e5546f353eb90c1bfd1
159
159
  zeitwerk (2.7.5) sha256=d8da92128c09ea6ec62c949011b00ed4a20242b255293dd66bf41545398f73dd
data/README.md CHANGED
@@ -139,6 +139,25 @@ user = WorkOS.client.user_management.create_user(
139
139
  puts user.id
140
140
  ```
141
141
 
142
+ ### Sealed sessions (cookie_password requirements)
143
+
144
+ When you use `client.session_manager` to seal session cookies, the
145
+ `cookie_password` you supply must be **at least 32 bytes** of high-entropy
146
+ secret material (typically 32 random bytes encoded as base64 or a 64-char
147
+ hex string). The SDK derives the AES-256-GCM key from this password via
148
+ SHA-256, and a passphrase shorter than 32 bytes makes the resulting key
149
+ materially easier to brute-force offline.
150
+
151
+ Generate a suitable secret once and store it as an environment variable:
152
+
153
+ ```sh
154
+ ruby -rsecurerandom -e 'puts SecureRandom.base64(32)'
155
+ ```
156
+
157
+ Anything shorter than 32 bytes (including `nil` or `""`) raises
158
+ `ArgumentError` at SDK init time — sealing or unsealing will not silently
159
+ proceed with a weakened key.
160
+
142
161
  ### Verify a webhook
143
162
 
144
163
  ```ruby
@@ -501,6 +501,27 @@ Session management was one of the largest refactors in v7. The old `WorkOS::Sess
501
501
 
502
502
  If your application seals session cookies, refreshes access tokens, or decodes the access-token JWT, every one of these call sites needs to be updated.
503
503
 
504
+ #### `cookie_password` minimum length (32 bytes)
505
+
506
+ v7 enforces a **minimum 32-byte length** on every `cookie_password` you supply
507
+ to the session manager (`load`, `seal_data`, `unseal_data`,
508
+ `seal_session_from_auth_response`, and the underlying `Encryptors::AesGcm`).
509
+
510
+ Anything shorter — including `nil` or `""` — now raises `ArgumentError` at the
511
+ moment the SDK is asked to seal or unseal. Older deployments that used a
512
+ short passphrase (e.g. a 16-character secret) will start erroring at app
513
+ boot or the next sealed-session request.
514
+
515
+ Pick a 32+ byte secret once and store it as an environment variable:
516
+
517
+ ```sh
518
+ ruby -rsecurerandom -e 'puts SecureRandom.base64(32)'
519
+ ```
520
+
521
+ The KDF itself (single-pass SHA-256) is unchanged in this release, so
522
+ existing sealed cookies continue to round-trip as long as the same
523
+ (now-length-validated) password is in use.
524
+
504
525
  #### Sealing a cookie from an authentication response
505
526
 
506
527
  In v6, you asked `authenticate_with_*` to seal the cookie for you:
@@ -35,7 +35,7 @@ module WorkOS
35
35
  def verify_header(payload:, sig_header:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS)
36
36
  timestamp_ms, signature_hash = parse_signature_header(sig_header)
37
37
  issued_at = timestamp_ms.to_i / 1000.0
38
- if (Time.now.to_f - issued_at) > tolerance
38
+ if (Time.now.to_f - issued_at).abs > tolerance
39
39
  raise WorkOS::SignatureVerificationError.new(
40
40
  message: "Timestamp outside the tolerance zone",
41
41
  http_status: nil
@@ -134,19 +134,33 @@ module WorkOS
134
134
  attempt = 0
135
135
 
136
136
  loop do
137
- log(:debug, "request start", method: request.method, path: request.path, attempt: attempt + 1)
137
+ loggable_path = redact_path(request.path)
138
+ log(:debug, "request start", method: request.method, path: loggable_path, attempt: attempt + 1)
138
139
  http = connection_for(base, timeout)
139
- response = http.request(request)
140
+ request_completed = false
141
+ begin
142
+ response = http.request(request)
143
+ request_completed = true
144
+ ensure
145
+ # Any exit from #request other than a returned response can leave
146
+ # the socket mid-stream: a connection error the rescue below knows
147
+ # about, one it doesn't (OpenSSL::SSL::SSLError,
148
+ # Net::HTTPBadResponse), or a non-local exit such as an
149
+ # application-level Timeout.timeout or Thread#kill. A half-read
150
+ # socket handed back to the pool desyncs the *next* request on this
151
+ # thread, so drop it here rather than in the rescue.
152
+ evict_connection(base) unless request_completed
153
+ end
140
154
  return response if response.is_a?(Net::HTTPSuccess)
141
155
 
142
156
  if attempt < retries && retryable?(response)
143
157
  attempt += 1
144
158
  inject_retry_idempotency_key(request)
145
- log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, status: response.code.to_i)
159
+ log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, status: response.code.to_i)
146
160
  sleep(retry_delay(response, attempt))
147
161
  next
148
162
  end
149
- log(:warn, "request error", method: request.method, path: request.path, status: response.code.to_i, request_id: response["x-request-id"] || response["X-Request-Id"])
163
+ log(:warn, "request error", method: request.method, path: loggable_path, status: response.code.to_i, request_id: response["x-request-id"] || response["X-Request-Id"])
150
164
  handle_error_response(response)
151
165
  rescue Net::OpenTimeout, Net::ReadTimeout,
152
166
  Errno::ECONNRESET, Errno::ECONNREFUSED,
@@ -155,11 +169,11 @@ module WorkOS
155
169
  if attempt < retries
156
170
  attempt += 1
157
171
  inject_retry_idempotency_key(request)
158
- log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, error: e.class.name)
172
+ log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, error: e.class.name)
159
173
  sleep(retry_delay(nil, attempt))
160
174
  next
161
175
  end
162
- log(:warn, "connection error", method: request.method, path: request.path, error: e.class.name, message: e.message)
176
+ log(:warn, "connection error", method: request.method, path: loggable_path, error: e.class.name, message: e.message)
163
177
  raise WorkOS::APIConnectionError.new(message: e.message)
164
178
  end
165
179
  end
@@ -179,6 +193,71 @@ module WorkOS
179
193
 
180
194
  private
181
195
 
196
+ # Redact path segments that carry bearer-equivalent tokens (e.g.
197
+ # `/user_management/invitations/by_token/<token>`,
198
+ # `/user_management/magic_auth/<token>`, password-reset / email-
199
+ # verification token paths) before the path is written to a logger.
200
+ # The WorkOS API exposes a small number of "by_token" endpoints whose
201
+ # path segments are themselves authentication material; redacting them
202
+ # here means the SDK never emits the token in its own log/retry/error
203
+ # messages even when the host application configures verbose logging.
204
+ REDACTED_TOKEN_PREFIXES = %w[
205
+ /user_management/invitations/by_token
206
+ /user_management/magic_auth
207
+ /user_management/password_reset
208
+ /user_management/email_verification
209
+ ].freeze
210
+ private_constant :REDACTED_TOKEN_PREFIXES
211
+
212
+ # Query-string keys whose values are bearer-equivalent or otherwise
213
+ # sensitive and should never appear in SDK log lines. Defense-in-depth
214
+ # for any path that flows through execute_request with a sensitive
215
+ # query parameter (most WorkOS-issued tokens are path segments or POST
216
+ # bodies, but a few flows — e.g. authorize/logout redirects — surface
217
+ # them in the query string).
218
+ REDACTED_QUERY_KEYS = %w[
219
+ token
220
+ code
221
+ code_challenge
222
+ code_verifier
223
+ session_id
224
+ refresh_token
225
+ access_token
226
+ ].freeze
227
+ private_constant :REDACTED_QUERY_KEYS
228
+
229
+ def redact_path(path)
230
+ return path if path.nil? || path.empty?
231
+
232
+ # Strip query string for the prefix match; reattach (scrubbed) after.
233
+ path_only, query = path.split("?", 2)
234
+ REDACTED_TOKEN_PREFIXES.each do |prefix|
235
+ next unless path_only.start_with?("#{prefix}/")
236
+
237
+ # Replace every segment after the matched prefix with "[REDACTED]".
238
+ remainder = path_only[(prefix.length + 1)..]
239
+ next if remainder.nil? || remainder.empty?
240
+
241
+ redacted = remainder.split("/").map { "[REDACTED]" }.join("/")
242
+ path_only = "#{prefix}/#{redacted}"
243
+ break
244
+ end
245
+ query ? "#{path_only}?#{redact_query(query)}" : path_only
246
+ end
247
+
248
+ def redact_query(query)
249
+ return query if query.nil? || query.empty?
250
+
251
+ query.split("&").map { |pair|
252
+ key, value = pair.split("=", 2)
253
+ if value && !value.empty? && REDACTED_QUERY_KEYS.include?(key)
254
+ "#{key}=[REDACTED]"
255
+ else
256
+ pair
257
+ end
258
+ }.join("&")
259
+ end
260
+
182
261
  def append_query(path, params)
183
262
  return path unless params.is_a?(Hash) && !params.empty?
184
263
 
@@ -14,8 +14,14 @@ module WorkOS
14
14
  module Encryptors
15
15
  class AesGcm
16
16
  SEAL_VERSION = 0x01
17
+ # Minimum cookie_password byte length. AES-256-GCM derives a 32-byte
18
+ # key from the password via SHA-256; a passphrase shorter than the
19
+ # output it derives to provides less than the full keyspace and makes
20
+ # offline brute-force feasible. See README + V7_MIGRATION_GUIDE.md.
21
+ MIN_KEY_BYTES = 32
17
22
 
18
23
  def seal(data, key)
24
+ validate_key!(key)
19
25
  json = data.is_a?(String) ? data : JSON.generate(data)
20
26
  cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt
21
27
  cipher.key = derive_key(key)
@@ -26,13 +32,16 @@ module WorkOS
26
32
  end
27
33
 
28
34
  def unseal(sealed, key)
35
+ validate_key!(key)
29
36
  raw = Base64.decode64(sealed.to_s)
30
- decode_v7(raw, key)
31
- rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error
32
37
  begin
33
- decode_old(raw, key)
34
- rescue ArgumentError, OpenSSL::Cipher::CipherError
35
- raise original_error
38
+ decode_v7(raw, key)
39
+ rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error
40
+ begin
41
+ decode_old(raw, key)
42
+ rescue ArgumentError, OpenSSL::Cipher::CipherError
43
+ raise original_error
44
+ end
36
45
  end
37
46
  end
38
47
 
@@ -83,6 +92,11 @@ module WorkOS
83
92
  def derive_key(passphrase)
84
93
  Digest::SHA256.digest(passphrase.to_s)
85
94
  end
95
+
96
+ def validate_key!(key)
97
+ raise ArgumentError, "cookie_password is required" if key.nil? || key.to_s.empty?
98
+ raise ArgumentError, "cookie_password must be at least #{MIN_KEY_BYTES} bytes" if key.to_s.bytesize < MIN_KEY_BYTES
99
+ end
86
100
  end
87
101
  end
88
102
  end
@@ -21,8 +21,16 @@ module WorkOS
21
21
  # @example Build a logout URL
22
22
  # url = session.get_logout_url(return_to: "https://app.example.com")
23
23
  class Session
24
+ # Minimum cookie_password byte length. AES-256-GCM derives a 32-byte
25
+ # key from the password via SHA-256; a passphrase shorter than the
26
+ # output it derives to provides less than the full keyspace and makes
27
+ # offline brute-force feasible. Require callers to supply at least 32
28
+ # bytes of high-entropy secret. See README + V7_MIGRATION_GUIDE.md.
29
+ MIN_COOKIE_PASSWORD_BYTES = 32
30
+
24
31
  def initialize(manager, seal_data:, cookie_password:)
25
32
  raise ArgumentError, "cookie_password is required" if cookie_password.nil? || cookie_password.empty?
33
+ raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if cookie_password.bytesize < MIN_COOKIE_PASSWORD_BYTES
26
34
  @manager = manager
27
35
  @client = manager.client
28
36
  @seal_data = seal_data
@@ -57,7 +65,7 @@ module WorkOS
57
65
  return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT)
58
66
  end
59
67
 
60
- is_expired = decoded["exp"] && decoded["exp"] < Time.now.to_i
68
+ is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i
61
69
 
62
70
  SessionManager::AuthSuccess.new(
63
71
  authenticated: !is_expired,
@@ -77,6 +85,11 @@ module WorkOS
77
85
 
78
86
  def refresh(organization_id: nil, cookie_password: nil)
79
87
  effective_password = cookie_password || @cookie_password
88
+ # Validate up front so a caller-supplied short password raises ArgumentError
89
+ # (matching Session#initialize) instead of being swallowed by the
90
+ # unseal_data rescue and surfacing as INVALID_SESSION_COOKIE.
91
+ raise ArgumentError, "cookie_password is required" if effective_password.nil? || effective_password.empty?
92
+ raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if effective_password.bytesize < MIN_COOKIE_PASSWORD_BYTES
80
93
 
81
94
  session = begin
82
95
  @manager.unseal_data(@seal_data, effective_password)
@@ -105,17 +118,20 @@ module WorkOS
105
118
  impersonator: auth_response["impersonator"]
106
119
  )
107
120
 
108
- # Decode before mutating session state so a malformed access_token
109
- # doesn't leave the Session half-updated.
110
- decoded = @manager.decode_jwt(auth_response["access_token"])
111
-
121
+ # Persist the new seal/password BEFORE decoding the JWT, so a transient
122
+ # JWKS fetch error (or any decode failure on the freshly-minted token)
123
+ # leaves the Session with a usable sealed cookie that the caller can
124
+ # re-#authenticate against, rather than half-updated state.
112
125
  @seal_data = sealed
113
126
  @cookie_password = effective_password
127
+
128
+ decoded = @manager.decode_jwt(auth_response["access_token"])
129
+
114
130
  SessionManager::RefreshSuccess.new(
115
131
  authenticated: true,
116
132
  sealed_session: sealed,
117
133
  session_id: decoded["sid"],
118
- organization_id: decoded["org_id"],
134
+ organization_id: auth_response["organization_id"] || decoded["org_id"],
119
135
  role: decoded["role"],
120
136
  roles: decoded["roles"],
121
137
  permissions: decoded["permissions"],
@@ -127,7 +143,12 @@ module WorkOS
127
143
  rescue WorkOS::AuthenticationError, WorkOS::InvalidRequestError => e
128
144
  SessionManager::RefreshError.new(authenticated: false, reason: e.message)
129
145
  rescue JWT::DecodeError => e
130
- SessionManager::RefreshError.new(authenticated: false, reason: e.message)
146
+ # The refresh token was already rotated server-side before decode failed,
147
+ # so @seal_data holds the freshly-minted cookie. Surface it on the error
148
+ # struct so the caller can write the rotated cookie back to the browser
149
+ # and recover on a subsequent #authenticate, rather than re-sending the
150
+ # now-revoked refresh token.
151
+ SessionManager::RefreshError.new(authenticated: false, reason: e.message, sealed_session: @seal_data)
131
152
  end
132
153
 
133
154
  # Build the WorkOS session-logout URL for the currently authenticated session.
@@ -107,7 +107,7 @@ module WorkOS
107
107
  :roles, :permissions, :entitlements, :user, :impersonator, :feature_flags,
108
108
  keyword_init: true
109
109
  )
110
- RefreshError = Struct.new(:authenticated, :reason, keyword_init: true)
110
+ RefreshError = Struct.new(:authenticated, :reason, :sealed_session, keyword_init: true)
111
111
 
112
112
  # Failure reason constants
113
113
  NO_SESSION_COOKIE_PROVIDED = "no_session_cookie_provided"
@@ -150,12 +150,14 @@ module WorkOS
150
150
  # H06 — Raw seal: encrypt arbitrary data with a key string.
151
151
  # Delegates to the configured encryptor (default: AES-256-GCM).
152
152
  def seal_data(data, key)
153
+ validate_cookie_password!(key)
153
154
  @encryptor.seal(data, key)
154
155
  end
155
156
 
156
157
  # H06 — Raw unseal: returns parsed JSON (Hash) or raw string if not JSON.
157
158
  # Delegates to the configured encryptor (default: AES-256-GCM).
158
159
  def unseal_data(sealed, key)
160
+ validate_cookie_password!(key)
159
161
  @encryptor.unseal(sealed, key)
160
162
  end
161
163
 
@@ -164,11 +166,20 @@ module WorkOS
164
166
  payload = {"access_token" => access_token, "refresh_token" => refresh_token}
165
167
  payload["user"] = user if user
166
168
  payload["impersonator"] = impersonator if impersonator
169
+ # Delegates to seal_data, which calls validate_cookie_password!; no need
170
+ # to validate here too.
167
171
  seal_data(payload, cookie_password)
168
172
  end
169
173
 
170
174
  # Verify an access-token JWT against the WorkOS JWKS for this client.
171
175
  # Used by Session#authenticate; exposed publicly for advanced cases.
176
+ #
177
+ # NOTE on iss/aud/required_claims: this method intentionally does not
178
+ # enforce iss, aud, or required_claims. workos-node's `jose` call and
179
+ # workos-php's `isset($exp) && $exp < time()` accept exp-less tokens, and
180
+ # cross-SDK parity is required for the planned coordinated hardening of
181
+ # these claims. See commit 9ce069f for the rationale behind dropping the
182
+ # required_claims: ['exp'] tightening that was considered here.
172
183
  def decode_jwt(access_token, verify_expiration: true)
173
184
  jwks = fetch_jwks
174
185
  JWT.decode(
@@ -182,6 +193,18 @@ module WorkOS
182
193
  ).first
183
194
  end
184
195
 
196
+ private
197
+
198
+ # Validate a cookie_password is non-empty and at least the minimum
199
+ # byte length required by Session::MIN_COOKIE_PASSWORD_BYTES (32).
200
+ # Defense-in-depth — Session#initialize enforces the same invariant
201
+ # on the load path; this guards the inline #seal_data / #unseal_data
202
+ # entry points.
203
+ def validate_cookie_password!(key)
204
+ raise ArgumentError, "cookie_password is required" if key.nil? || key.empty?
205
+ raise ArgumentError, "cookie_password must be at least #{Session::MIN_COOKIE_PASSWORD_BYTES} bytes" if key.bytesize < Session::MIN_COOKIE_PASSWORD_BYTES
206
+ end
207
+
185
208
  # Cached JWKS fetch (5-minute TTL, thread-safe).
186
209
  def fetch_jwks(now: Time.now)
187
210
  @jwks_mutex.synchronize do
@@ -1641,6 +1641,13 @@ module WorkOS
1641
1641
  def get_authorization_url_with_pkce(redirect_uri:, client_id: nil, **opts)
1642
1642
  pair = WorkOS::PKCE.generate_pair
1643
1643
  state = opts.delete(:state) || WorkOS::PKCE.generate_code_verifier
1644
+ # Strip caller-supplied PKCE params: this helper exists specifically
1645
+ # to generate them, so a caller-provided value would either silently
1646
+ # override our freshly-generated challenge (defeating the helper) or
1647
+ # collide with the keyword args below and raise. Mirror the existing
1648
+ # opts.delete(:state) pattern.
1649
+ opts.delete(:code_challenge)
1650
+ opts.delete(:code_challenge_method)
1644
1651
  url = get_authorization_url(
1645
1652
  redirect_uri: redirect_uri,
1646
1653
  client_id: client_id,
@@ -2,5 +2,5 @@
2
2
 
3
3
  # @oagen-ignore-file
4
4
  module WorkOS
5
- VERSION = "8.0.0"
5
+ VERSION = "8.0.2"
6
6
  end
@@ -193,7 +193,7 @@ module WorkOS
193
193
  timestamp_ms, signature_hash = parse_signature_header(sig_header)
194
194
  max_age = tolerance.to_i
195
195
  issued_at = timestamp_ms.to_i / 1000.0
196
- if (Time.now.to_f - issued_at) > max_age
196
+ if (Time.now.to_f - issued_at).abs > max_age
197
197
  raise WorkOS::SignatureVerificationError.new(
198
198
  message: "Timestamp outside the tolerance zone",
199
199
  http_status: nil
@@ -49,6 +49,15 @@ class ActionsTest < Minitest::Test
49
49
  end
50
50
  end
51
51
 
52
+ def test_verify_header_raises_on_future_timestamp
53
+ payload = '{"x":1}'
54
+ future_ts = now_ms + 60_000 # 60s ahead, beyond default 30s tolerance
55
+ sig = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{future_ts}.#{payload}")
56
+ assert_raises(WorkOS::SignatureVerificationError) do
57
+ @actions.verify_header(payload: payload, sig_header: "t=#{future_ts}, v1=#{sig}", secret: SECRET)
58
+ end
59
+ end
60
+
52
61
  def test_sign_response_authentication_allow
53
62
  resp = @actions.sign_response(action_type: "authentication", verdict: "Allow", secret: SECRET)
54
63
  assert_equal "authentication_action_response", resp["object"]
@@ -82,10 +82,36 @@ class BaseClientTest < Minitest::Test
82
82
  end
83
83
  end
84
84
 
85
+ # A pooled connection that either returns a canned response or raises when
86
+ # driven, so execute_request can be exercised without real TCP+TLS.
87
+ class StubConnection < FakeConnection
88
+ attr_accessor :read_timeout, :open_timeout
89
+
90
+ def initialize(response: nil, error: nil, **kwargs)
91
+ super(**kwargs)
92
+ @response = response
93
+ @error = error
94
+ end
95
+
96
+ def request(_request)
97
+ raise @error if @error
98
+
99
+ @response
100
+ end
101
+ end
102
+
85
103
  def setup
86
104
  @client = WorkOS::BaseClient.new(api_key: "sk_test_123", max_retries: 1)
87
105
  end
88
106
 
107
+ def teardown
108
+ super
109
+ # Close any open connections and clear the fiber-local cache to avoid
110
+ # leaking pooled connections between tests.
111
+ @client.shutdown
112
+ Fiber[:workos_connections] = nil
113
+ end
114
+
89
115
  def test_request_dispatches_known_methods
90
116
  client = RecordingClient.new(api_key: "sk_test_123")
91
117
 
@@ -170,4 +196,185 @@ class BaseClientTest < Minitest::Test
170
196
  assert evict.finished
171
197
  refute keep.finished
172
198
  end
199
+
200
+ def test_redact_path_strips_invitation_token_segment
201
+ redacted = @client.send(:redact_path, "/user_management/invitations/by_token/invtoken_secret123")
202
+ assert_equal "/user_management/invitations/by_token/[REDACTED]", redacted
203
+ end
204
+
205
+ def test_redact_path_strips_magic_auth_token_segment
206
+ redacted = @client.send(:redact_path, "/user_management/magic_auth/magic_secret/extra")
207
+ assert_equal "/user_management/magic_auth/[REDACTED]/[REDACTED]", redacted
208
+ end
209
+
210
+ def test_redact_path_preserves_non_token_paths
211
+ assert_equal "/organizations/org_123", @client.send(:redact_path, "/organizations/org_123")
212
+ end
213
+
214
+ def test_redact_path_preserves_query_string
215
+ redacted = @client.send(:redact_path, "/user_management/invitations/by_token/secret?foo=bar")
216
+ assert_equal "/user_management/invitations/by_token/[REDACTED]?foo=bar", redacted
217
+ end
218
+
219
+ def test_redact_path_handles_nil_and_empty
220
+ assert_nil @client.send(:redact_path, nil)
221
+ assert_equal "", @client.send(:redact_path, "")
222
+ end
223
+
224
+ def test_redact_path_scrubs_sensitive_query_params
225
+ redacted = @client.send(:redact_path, "/user_management/sessions/logout?session_id=ses_abc123&return_to=https://app.example.com")
226
+ assert_equal "/user_management/sessions/logout?session_id=[REDACTED]&return_to=https://app.example.com", redacted
227
+ end
228
+
229
+ def test_redact_path_scrubs_authorize_code_query_param
230
+ redacted = @client.send(:redact_path, "/user_management/authorize?client_id=client_1&code=auth_code_secret&state=xyz")
231
+ assert_equal "/user_management/authorize?client_id=client_1&code=[REDACTED]&state=xyz", redacted
232
+ end
233
+
234
+ def test_redact_path_leaves_non_sensitive_query_params_untouched
235
+ redacted = @client.send(:redact_path, "/user_management/users?limit=10&order=desc")
236
+ assert_equal "/user_management/users?limit=10&order=desc", redacted
237
+ end
238
+
239
+ def test_redact_path_scrubs_query_alongside_path_segment_redaction
240
+ redacted = @client.send(:redact_path, "/user_management/magic_auth/magic_secret?token=qs_token")
241
+ assert_equal "/user_management/magic_auth/[REDACTED]?token=[REDACTED]", redacted
242
+ end
243
+
244
+ # An exception execute_request's rescue clause doesn't list still leaves the
245
+ # socket mid-stream, so the connection must not survive in the pool for the
246
+ # next request on this thread to pick up.
247
+ def test_unlisted_request_error_evicts_the_pooled_connection
248
+ conn = StubConnection.new(error: Net::HTTPBadResponse.new("wrong version"))
249
+ cache = @client.send(:thread_connections)
250
+ cache["https:api.workos.com:443:30"] = conn
251
+
252
+ assert_raises(Net::HTTPBadResponse) do
253
+ @client.execute_request(request: Net::HTTP::Get.new("/things"))
254
+ end
255
+
256
+ refute cache.key?("https:api.workos.com:443:30"),
257
+ "a connection whose request did not complete must not stay pooled"
258
+ assert conn.finished
259
+ end
260
+
261
+ def test_completed_request_keeps_the_connection_pooled
262
+ conn = StubConnection.new(response: Net::HTTPOK.new("1.1", "200", "OK"))
263
+ cache = @client.send(:thread_connections)
264
+ cache["https:api.workos.com:443:30"] = conn
265
+
266
+ @client.execute_request(request: Net::HTTP::Get.new("/things"))
267
+
268
+ assert cache.key?("https:api.workos.com:443:30")
269
+ refute conn.finished
270
+ end
271
+
272
+ # Raised asynchronously into the worker thread to stand in for a caller-side
273
+ # abort: Timeout.timeout, Thread#kill, a signal handler unwinding the stack.
274
+ # It descends from Exception rather than StandardError so that nothing in
275
+ # execute_request can catch it — the `ensure` is the only cleanup that runs.
276
+ class AbortSignal < Exception; end # standard:disable Lint/InheritException
277
+
278
+ # End-to-end regression test over a real keep-alive socket, for the failure
279
+ # the eviction actually prevents: request one is abandoned mid-flight, the
280
+ # server then writes response one onto that socket, and request two on the
281
+ # same thread reads those stale bytes as if they were its own response.
282
+ #
283
+ # Before the fix, request two sees marker "one" (or a mangled response) off
284
+ # the pooled socket. After it, the abandoned socket is closed and the server
285
+ # accepts a fresh connection for request two.
286
+ def test_aborted_request_does_not_leak_its_response_to_the_next_request
287
+ WebMock.disable!
288
+ server = TCPServer.new("127.0.0.1", 0)
289
+ port = server.addr[1]
290
+ client = WorkOS::BaseClient.new(
291
+ api_key: "sk_test_123",
292
+ base_url: "http://127.0.0.1:#{port}",
293
+ timeout: 5,
294
+ max_retries: 0
295
+ )
296
+
297
+ request_one_read = Queue.new
298
+ release_response_one = Queue.new
299
+ response_one_written = Queue.new
300
+ aborted = Queue.new
301
+ connections = Queue.new
302
+
303
+ server_thread = Thread.new do
304
+ # Connection one: read the request, then hold the response back until
305
+ # the client has been aborted and has unwound.
306
+ first = server.accept
307
+ connections << first
308
+ read_http_request(first)
309
+ request_one_read << true
310
+ release_response_one.pop
311
+ begin
312
+ write_http_response(first, {marker: "one"})
313
+ rescue Errno::EPIPE, Errno::ECONNRESET, IOError
314
+ # Expected once the fix closes the abandoned socket.
315
+ end
316
+ response_one_written << true
317
+
318
+ # Connection two: a fresh accept, which only completes because the
319
+ # client did not reuse the socket above.
320
+ second = server.accept
321
+ connections << second
322
+ read_http_request(second)
323
+ write_http_response(second, {marker: "two"})
324
+ end
325
+
326
+ worker = Thread.new do
327
+ begin
328
+ client.execute_request(request: Net::HTTP::Get.new("/one"))
329
+ rescue AbortSignal
330
+ # The caller unwinds but the thread survives and goes on to serve more
331
+ # work, the way a Puma or Solid Queue worker does. execute_request's
332
+ # `ensure` has already run by the time this body executes.
333
+ aborted << true
334
+ response_one_written.pop
335
+ end
336
+ response = client.execute_request(request: Net::HTTP::Get.new("/two"))
337
+ JSON.parse(response.body)["marker"]
338
+ end
339
+
340
+ marker = Timeout.timeout(15) do
341
+ request_one_read.pop
342
+ worker.raise(AbortSignal)
343
+ aborted.pop
344
+ release_response_one << true
345
+ worker.value
346
+ end
347
+
348
+ assert_equal "two", marker,
349
+ "request two read the abandoned socket's response instead of its own"
350
+ ensure
351
+ server_thread&.kill
352
+ worker&.kill
353
+ until connections.nil? || connections.empty?
354
+ socket = connections.pop
355
+ socket.close unless socket.closed?
356
+ end
357
+ server&.close
358
+ WebMock.enable!
359
+ end
360
+
361
+ def read_http_request(socket)
362
+ socket.gets # request line
363
+ loop do
364
+ line = socket.gets
365
+ break if line.nil? || line == "\r\n"
366
+ end
367
+ end
368
+
369
+ def write_http_response(socket, body)
370
+ payload = JSON.generate(body)
371
+ socket.write(
372
+ "HTTP/1.1 200 OK\r\n" \
373
+ "Content-Type: application/json\r\n" \
374
+ "Content-Length: #{payload.bytesize}\r\n" \
375
+ "Connection: keep-alive\r\n" \
376
+ "\r\n#{payload}"
377
+ )
378
+ socket.flush
379
+ end
173
380
  end
@@ -28,11 +28,26 @@ class EncryptorsAesGcmTest < Minitest::Test
28
28
 
29
29
  def test_unseal_with_wrong_key_raises
30
30
  sealed = @enc.seal({"x" => 1}, PASSWORD)
31
+ # Wrong key is the same length (>= 32 bytes) so the length guard doesn't
32
+ # short-circuit; we want to assert the underlying cipher rejection.
31
33
  assert_raises(OpenSSL::Cipher::CipherError) do
32
- @enc.unseal(sealed, "wrong-password")
34
+ @enc.unseal(sealed, "wrong-cookie-password-32-bytes--")
33
35
  end
34
36
  end
35
37
 
38
+ def test_unseal_rejects_short_key
39
+ sealed = @enc.seal({"x" => 1}, PASSWORD)
40
+ assert_raises(ArgumentError) do
41
+ @enc.unseal(sealed, "too-short")
42
+ end
43
+ end
44
+
45
+ def test_seal_rejects_short_key
46
+ assert_raises(ArgumentError) { @enc.seal({"x" => 1}, "too-short") }
47
+ assert_raises(ArgumentError) { @enc.seal({"x" => 1}, nil) }
48
+ assert_raises(ArgumentError) { @enc.seal({"x" => 1}, "") }
49
+ end
50
+
36
51
  def test_unseal_rejects_short_payload
37
52
  assert_raises(ArgumentError) do
38
53
  @enc.unseal(Base64.strict_encode64("short"), PASSWORD)
@@ -26,11 +26,27 @@ class SessionTest < Minitest::Test
26
26
 
27
27
  def test_unseal_with_wrong_key_raises
28
28
  sealed = @sm.seal_data({"x" => 1}, PASSWORD)
29
+ # Wrong key is the same length (>= 32 bytes) so the length guard doesn't
30
+ # short-circuit; we want to assert the underlying cipher rejection.
29
31
  assert_raises(OpenSSL::Cipher::CipherError) do
30
- @sm.unseal_data(sealed, "wrong-password")
32
+ @sm.unseal_data(sealed, "wrong-cookie-password-32-bytes--")
31
33
  end
32
34
  end
33
35
 
36
+ def test_unseal_with_short_key_raises_argument_error
37
+ sealed = @sm.seal_data({"x" => 1}, PASSWORD)
38
+ assert_raises(ArgumentError) { @sm.unseal_data(sealed, "too-short") }
39
+ end
40
+
41
+ def test_seal_with_short_key_raises_argument_error
42
+ assert_raises(ArgumentError) { @sm.seal_data({"x" => 1}, "too-short") }
43
+ end
44
+
45
+ def test_session_load_requires_min_length_cookie_password
46
+ short = "x" * 31
47
+ assert_raises(ArgumentError) { @sm.load(seal_data: "x", cookie_password: short) }
48
+ end
49
+
34
50
  def test_unseal_rejects_short_payload
35
51
  assert_raises(ArgumentError) do
36
52
  @sm.unseal_data(Base64.strict_encode64("short"), PASSWORD)
@@ -334,6 +350,19 @@ class SessionTest < Minitest::Test
334
350
  assert_equal WorkOS::SessionManager::INVALID_SESSION_COOKIE, result.reason
335
351
  end
336
352
 
353
+ def test_refresh_raises_argument_error_for_short_cookie_password_override
354
+ sealed = @sm.seal_data({"access_token" => "at", "refresh_token" => "rt"}, PASSWORD)
355
+ session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
356
+ err = assert_raises(ArgumentError) { session.refresh(cookie_password: "x" * 31) }
357
+ assert_match(/at least 32 bytes/, err.message)
358
+ end
359
+
360
+ def test_refresh_raises_argument_error_for_empty_cookie_password_override
361
+ sealed = @sm.seal_data({"access_token" => "at", "refresh_token" => "rt"}, PASSWORD)
362
+ session = @sm.load(seal_data: sealed, cookie_password: PASSWORD)
363
+ assert_raises(ArgumentError) { session.refresh(cookie_password: "") }
364
+ end
365
+
337
366
  def test_refresh_returns_error_when_no_refresh_token
338
367
  sealed = @sm.seal_data({"access_token" => "at_only"}, PASSWORD)
339
368
  result = @sm.refresh(seal_data: sealed, cookie_password: PASSWORD)
@@ -361,7 +390,7 @@ class SessionTest < Minitest::Test
361
390
  assert_requested(stub)
362
391
  end
363
392
 
364
- def test_refresh_returns_error_on_malformed_access_token_without_mutating_state
393
+ def test_refresh_persists_seal_data_even_when_access_token_decode_fails
365
394
  rsa, pub = signing_key_pair
366
395
  old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa)
367
396
  sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD)
@@ -383,8 +412,18 @@ class SessionTest < Minitest::Test
383
412
  assert_kind_of WorkOS::SessionManager::RefreshError, result
384
413
  refute result.authenticated
385
414
 
386
- # Session state should not have been mutated
387
- assert_equal sealed, session.seal_data
415
+ # Session state IS updated to the freshly-sealed cookie before decode runs,
416
+ # so a transient JWT/JWKS failure leaves a usable seal the caller can
417
+ # re-#authenticate against rather than half-updated state pinned to the
418
+ # stale (already-rotated) refresh token.
419
+ refute_equal sealed, session.seal_data
420
+ refute_nil session.seal_data
421
+
422
+ # The rotated cookie is also reachable through the RefreshError result, so a
423
+ # caller that doesn't retain the Session object across requests (typical in
424
+ # a Rails request cycle) can still write the new cookie back to the browser
425
+ # rather than re-sending the now-revoked refresh token on the next request.
426
+ assert_equal session.seal_data, result.sealed_session
388
427
  end
389
428
 
390
429
  # --- Session constructor validation ---------------------------------------
@@ -60,6 +60,17 @@ class WebhookVerifyTest < Minitest::Test
60
60
  assert_match(/Timestamp outside the tolerance zone/, err.message)
61
61
  end
62
62
 
63
+ def test_verify_header_raises_on_future_timestamp
64
+ payload = '{"x":1}'
65
+ future_ts = now_ms + (10 * 60 * 1000) # 10 minutes ahead
66
+ sig = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{future_ts}.#{payload}")
67
+ header = "t=#{future_ts}, v1=#{sig}"
68
+ err = assert_raises(WorkOS::SignatureVerificationError) do
69
+ @webhooks.verify_header(payload: payload, sig_header: header, secret: SECRET, tolerance: 60)
70
+ end
71
+ assert_match(/Timestamp outside the tolerance zone/, err.message)
72
+ end
73
+
63
74
  def test_verify_header_raises_on_malformed_header
64
75
  assert_raises(WorkOS::SignatureVerificationError) do
65
76
  @webhooks.verify_header(payload: "{}", sig_header: "garbage", secret: SECRET)
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: workos
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.0
4
+ version: 8.0.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - WorkOS
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-05-06 00:00:00.000000000 Z
11
+ date: 2026-08-20 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt