workos 8.0.0 → 8.0.1

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: ca01c2ba88ce0aff45ae8689e584ba67896309cefeda715c18b119094e238a7b
4
+ data.tar.gz: 6733400a815cf3e8274e5eda8f95dbf2bfa9ea65da27fcc8b59e027f85b6182c
5
5
  SHA512:
6
- metadata.gz: 9ecabceabdc1847645a1a07fa7d747bfde8bdf8fe1cb86c487d44b5b2d0a87c49e41bb32d49c8511009970bd3de64ac13b6674616a3b8026ae10bccfd337a81b
7
- data.tar.gz: 41d43875ebc5c9de7f87b8ce3e810795846c6077ae79c8f752fdd8894c6b5de574bd5e594d3a2a6a77ef7853b94ee04fe2d811b52cf69576bebcd6812fab9c70
6
+ metadata.gz: 79ed8ec1968491abfdb5d884988176fd02d9b3ddd74e777e59d7f0cf47609a3f9f1c45bfd7e1cca94624f1734b7f2a95c8f71da9c27c32f3a843a98cd720a5bf
7
+ data.tar.gz: e72fca2d3bf036ee5b134500ba001c7f35db82473edde30c4fcc748e2e492d0450a9d8ce4f1d11c43376672257d7ca51d8ecc0663180e40a3c02d5ab3572d0d4
@@ -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 }}
@@ -45,8 +45,8 @@ jobs:
45
45
  if git diff --quiet Gemfile.lock; then
46
46
  echo "Gemfile.lock is up to date"
47
47
  else
48
- git config user.name "github-actions[bot]"
49
- git config user.email "github-actions[bot]@users.noreply.github.com"
48
+ git config user.name "workos-sdk-automation[bot]"
49
+ git config user.email "255426317+workos-sdk-automation[bot]@users.noreply.github.com"
50
50
  git add Gemfile.lock
51
51
  git commit -m "chore: update Gemfile.lock"
52
52
  git push
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "8.0.0"
2
+ ".": "8.0.1"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [8.0.1](https://github.com/workos/workos-ruby/compare/v8.0.0...v8.0.1) (2026-05-12)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 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))
9
+
3
10
  ## [8.0.0](https://github.com/workos/workos-ruby/compare/v7.1.2...v8.0.0) (2026-05-06)
4
11
 
5
12
 
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.1)
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.1)
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,7 +134,8 @@ 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
140
  response = http.request(request)
140
141
  return response if response.is_a?(Net::HTTPSuccess)
@@ -142,11 +143,11 @@ module WorkOS
142
143
  if attempt < retries && retryable?(response)
143
144
  attempt += 1
144
145
  inject_retry_idempotency_key(request)
145
- log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, status: response.code.to_i)
146
+ log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, status: response.code.to_i)
146
147
  sleep(retry_delay(response, attempt))
147
148
  next
148
149
  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"])
150
+ 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
151
  handle_error_response(response)
151
152
  rescue Net::OpenTimeout, Net::ReadTimeout,
152
153
  Errno::ECONNRESET, Errno::ECONNREFUSED,
@@ -155,11 +156,11 @@ module WorkOS
155
156
  if attempt < retries
156
157
  attempt += 1
157
158
  inject_retry_idempotency_key(request)
158
- log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, error: e.class.name)
159
+ log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, error: e.class.name)
159
160
  sleep(retry_delay(nil, attempt))
160
161
  next
161
162
  end
162
- log(:warn, "connection error", method: request.method, path: request.path, error: e.class.name, message: e.message)
163
+ log(:warn, "connection error", method: request.method, path: loggable_path, error: e.class.name, message: e.message)
163
164
  raise WorkOS::APIConnectionError.new(message: e.message)
164
165
  end
165
166
  end
@@ -179,6 +180,71 @@ module WorkOS
179
180
 
180
181
  private
181
182
 
183
+ # Redact path segments that carry bearer-equivalent tokens (e.g.
184
+ # `/user_management/invitations/by_token/<token>`,
185
+ # `/user_management/magic_auth/<token>`, password-reset / email-
186
+ # verification token paths) before the path is written to a logger.
187
+ # The WorkOS API exposes a small number of "by_token" endpoints whose
188
+ # path segments are themselves authentication material; redacting them
189
+ # here means the SDK never emits the token in its own log/retry/error
190
+ # messages even when the host application configures verbose logging.
191
+ REDACTED_TOKEN_PREFIXES = %w[
192
+ /user_management/invitations/by_token
193
+ /user_management/magic_auth
194
+ /user_management/password_reset
195
+ /user_management/email_verification
196
+ ].freeze
197
+ private_constant :REDACTED_TOKEN_PREFIXES
198
+
199
+ # Query-string keys whose values are bearer-equivalent or otherwise
200
+ # sensitive and should never appear in SDK log lines. Defense-in-depth
201
+ # for any path that flows through execute_request with a sensitive
202
+ # query parameter (most WorkOS-issued tokens are path segments or POST
203
+ # bodies, but a few flows — e.g. authorize/logout redirects — surface
204
+ # them in the query string).
205
+ REDACTED_QUERY_KEYS = %w[
206
+ token
207
+ code
208
+ code_challenge
209
+ code_verifier
210
+ session_id
211
+ refresh_token
212
+ access_token
213
+ ].freeze
214
+ private_constant :REDACTED_QUERY_KEYS
215
+
216
+ def redact_path(path)
217
+ return path if path.nil? || path.empty?
218
+
219
+ # Strip query string for the prefix match; reattach (scrubbed) after.
220
+ path_only, query = path.split("?", 2)
221
+ REDACTED_TOKEN_PREFIXES.each do |prefix|
222
+ next unless path_only.start_with?("#{prefix}/")
223
+
224
+ # Replace every segment after the matched prefix with "[REDACTED]".
225
+ remainder = path_only[(prefix.length + 1)..]
226
+ next if remainder.nil? || remainder.empty?
227
+
228
+ redacted = remainder.split("/").map { "[REDACTED]" }.join("/")
229
+ path_only = "#{prefix}/#{redacted}"
230
+ break
231
+ end
232
+ query ? "#{path_only}?#{redact_query(query)}" : path_only
233
+ end
234
+
235
+ def redact_query(query)
236
+ return query if query.nil? || query.empty?
237
+
238
+ query.split("&").map { |pair|
239
+ key, value = pair.split("=", 2)
240
+ if value && !value.empty? && REDACTED_QUERY_KEYS.include?(key)
241
+ "#{key}=[REDACTED]"
242
+ else
243
+ pair
244
+ end
245
+ }.join("&")
246
+ end
247
+
182
248
  def append_query(path, params)
183
249
  return path unless params.is_a?(Hash) && !params.empty?
184
250
 
@@ -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.1"
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"]
@@ -170,4 +170,48 @@ class BaseClientTest < Minitest::Test
170
170
  assert evict.finished
171
171
  refute keep.finished
172
172
  end
173
+
174
+ def test_redact_path_strips_invitation_token_segment
175
+ redacted = @client.send(:redact_path, "/user_management/invitations/by_token/invtoken_secret123")
176
+ assert_equal "/user_management/invitations/by_token/[REDACTED]", redacted
177
+ end
178
+
179
+ def test_redact_path_strips_magic_auth_token_segment
180
+ redacted = @client.send(:redact_path, "/user_management/magic_auth/magic_secret/extra")
181
+ assert_equal "/user_management/magic_auth/[REDACTED]/[REDACTED]", redacted
182
+ end
183
+
184
+ def test_redact_path_preserves_non_token_paths
185
+ assert_equal "/organizations/org_123", @client.send(:redact_path, "/organizations/org_123")
186
+ end
187
+
188
+ def test_redact_path_preserves_query_string
189
+ redacted = @client.send(:redact_path, "/user_management/invitations/by_token/secret?foo=bar")
190
+ assert_equal "/user_management/invitations/by_token/[REDACTED]?foo=bar", redacted
191
+ end
192
+
193
+ def test_redact_path_handles_nil_and_empty
194
+ assert_nil @client.send(:redact_path, nil)
195
+ assert_equal "", @client.send(:redact_path, "")
196
+ end
197
+
198
+ def test_redact_path_scrubs_sensitive_query_params
199
+ redacted = @client.send(:redact_path, "/user_management/sessions/logout?session_id=ses_abc123&return_to=https://app.example.com")
200
+ assert_equal "/user_management/sessions/logout?session_id=[REDACTED]&return_to=https://app.example.com", redacted
201
+ end
202
+
203
+ def test_redact_path_scrubs_authorize_code_query_param
204
+ redacted = @client.send(:redact_path, "/user_management/authorize?client_id=client_1&code=auth_code_secret&state=xyz")
205
+ assert_equal "/user_management/authorize?client_id=client_1&code=[REDACTED]&state=xyz", redacted
206
+ end
207
+
208
+ def test_redact_path_leaves_non_sensitive_query_params_untouched
209
+ redacted = @client.send(:redact_path, "/user_management/users?limit=10&order=desc")
210
+ assert_equal "/user_management/users?limit=10&order=desc", redacted
211
+ end
212
+
213
+ def test_redact_path_scrubs_query_alongside_path_segment_redaction
214
+ redacted = @client.send(:redact_path, "/user_management/magic_auth/magic_secret?token=qs_token")
215
+ assert_equal "/user_management/magic_auth/[REDACTED]?token=[REDACTED]", redacted
216
+ end
173
217
  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.1
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-05-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: jwt