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 +4 -4
- data/.github/workflows/docs.yml +4 -1
- data/.github/workflows/release-please.yml +4 -3
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +14 -0
- data/Gemfile.lock +2 -2
- data/README.md +19 -0
- data/docs/V7_MIGRATION_GUIDE.md +21 -0
- data/lib/workos/actions.rb +1 -1
- data/lib/workos/base_client.rb +85 -6
- data/lib/workos/encryptors/aes_gcm.rb +19 -5
- data/lib/workos/session.rb +28 -7
- data/lib/workos/session_manager.rb +24 -1
- data/lib/workos/user_management.rb +7 -0
- data/lib/workos/version.rb +1 -1
- data/lib/workos/webhooks.rb +1 -1
- data/test/workos/test_actions.rb +9 -0
- data/test/workos/test_base_client.rb +207 -0
- data/test/workos/test_encryptors_aes_gcm.rb +16 -1
- data/test/workos/test_session.rb +43 -4
- data/test/workos/test_webhook_verify.rb +11 -0
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 24ec60a2134602d268907b2a885e04872b8ad5bb7ccff1095abb1a1d7bbb30d7
|
|
4
|
+
data.tar.gz: 1ef26bc5f186054f6949156b127d64ad3b1533c38468e0c919105b2ce7e0dd4c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81b868876a7b9541e043f8ed407b8a0ef1879df9d96ce1163ea737ea037132c80a036a03636165f53e0c96e9a0a52bdc6a8775fc84e953df1be9aa8646e78e34
|
|
7
|
+
data.tar.gz: 9118c02daa8efe8812fb999fd1ac745bc1760f7e6a0613fe34f4272489eff27ebe552e653b2b7dab36de54b8ef06919a81936873af8741e2c7f57e9b4f71898c
|
data/.github/workflows/docs.yml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
name: Publish API Docs
|
|
2
2
|
on:
|
|
3
3
|
push:
|
|
4
|
-
|
|
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
|
-
-
|
|
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 "
|
|
49
|
-
git config user.email "
|
|
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
|
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.
|
|
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.
|
|
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
|
data/docs/V7_MIGRATION_GUIDE.md
CHANGED
|
@@ -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:
|
data/lib/workos/actions.rb
CHANGED
|
@@ -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
|
data/lib/workos/base_client.rb
CHANGED
|
@@ -134,19 +134,33 @@ module WorkOS
|
|
|
134
134
|
attempt = 0
|
|
135
135
|
|
|
136
136
|
loop do
|
|
137
|
-
|
|
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
|
+
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
34
|
-
rescue ArgumentError, OpenSSL::Cipher::CipherError
|
|
35
|
-
|
|
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
|
data/lib/workos/session.rb
CHANGED
|
@@ -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"]
|
|
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
|
-
#
|
|
109
|
-
#
|
|
110
|
-
|
|
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
|
-
|
|
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,
|
data/lib/workos/version.rb
CHANGED
data/lib/workos/webhooks.rb
CHANGED
|
@@ -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
|
data/test/workos/test_actions.rb
CHANGED
|
@@ -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)
|
data/test/workos/test_session.rb
CHANGED
|
@@ -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
|
|
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
|
|
387
|
-
|
|
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.
|
|
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-
|
|
11
|
+
date: 2026-08-20 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: jwt
|