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 +4 -4
- data/.github/workflows/docs.yml +4 -1
- data/.github/workflows/release-please.yml +2 -2
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +7 -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 +71 -5
- 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 +44 -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: ca01c2ba88ce0aff45ae8689e584ba67896309cefeda715c18b119094e238a7b
|
|
4
|
+
data.tar.gz: 6733400a815cf3e8274e5eda8f95dbf2bfa9ea65da27fcc8b59e027f85b6182c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79ed8ec1968491abfdb5d884988176fd02d9b3ddd74e777e59d7f0cf47609a3f9f1c45bfd7e1cca94624f1734b7f2a95c8f71da9c27c32f3a843a98cd720a5bf
|
|
7
|
+
data.tar.gz: e72fca2d3bf036ee5b134500ba001c7f35db82473edde30c4fcc748e2e492d0450a9d8ce4f1d11c43376672257d7ca51d8ecc0663180e40a3c02d5ab3572d0d4
|
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 }}
|
|
@@ -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 "
|
|
49
|
-
git config user.email "
|
|
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
|
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.
|
|
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.
|
|
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
|
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,7 +134,8 @@ 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
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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"]
|
|
@@ -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)
|
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.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-
|
|
11
|
+
date: 2026-05-12 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: jwt
|