From 536cb9b5fe734930531a8d11e8e740a5774099ee Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:35:56 -0700 Subject: [PATCH 01/11] fix(session): guard nil/empty cookie_password in SessionManager seal helpers Mirror the existing Session#initialize guard inside SessionManager#seal_data, #unseal_data, and #seal_session_from_auth_response. Previously these public helpers would happily seal or attempt to unseal with a nil/empty key, which collapses to a deterministic SHA-256 of the empty string and silently weakens session-cookie confidentiality. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/session_manager.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/workos/session_manager.rb b/lib/workos/session_manager.rb index 7d787536..239734f6 100644 --- a/lib/workos/session_manager.rb +++ b/lib/workos/session_manager.rb @@ -150,17 +150,20 @@ def refresh(seal_data:, cookie_password:, organization_id: nil) # H06 — Raw seal: encrypt arbitrary data with a key string. # Delegates to the configured encryptor (default: AES-256-GCM). def seal_data(data, key) + raise ArgumentError, "cookie_password is required" if key.nil? || key.empty? @encryptor.seal(data, key) end # H06 — Raw unseal: returns parsed JSON (Hash) or raw string if not JSON. # Delegates to the configured encryptor (default: AES-256-GCM). def unseal_data(sealed, key) + raise ArgumentError, "cookie_password is required" if key.nil? || key.empty? @encryptor.unseal(sealed, key) end # H07 — Build a sealed session string directly from auth-response fields. def seal_session_from_auth_response(access_token:, refresh_token:, cookie_password:, user: nil, impersonator: nil) + raise ArgumentError, "cookie_password is required" if cookie_password.nil? || cookie_password.empty? payload = {"access_token" => access_token, "refresh_token" => refresh_token} payload["user"] = user if user payload["impersonator"] = impersonator if impersonator From a48a19517ffbf7ce6ba2436bdf0cfce03ff77baa Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:36:16 -0700 Subject: [PATCH 02/11] fix(session): require exp claim on access-token JWT decode Pass required_claims: ['exp'] to JWT.decode so a token missing exp is rejected by ruby-jwt (raises JWT::MissingRequiredClaim, which already flows into the existing JWT::DecodeError rescue and yields INVALID_JWT). Defense in depth: also treat decoded["exp"].nil? as expired in Session#authenticate so the include_expired: true branch can't return authenticated: true on a token without an expiry. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/session.rb | 2 +- lib/workos/session_manager.rb | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 5165fb32..0583ec16 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -57,7 +57,7 @@ def authenticate(include_expired: false, &claim_extractor) return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT) end - is_expired = decoded["exp"] && decoded["exp"] < Time.now.to_i + is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i SessionManager::AuthSuccess.new( authenticated: !is_expired, diff --git a/lib/workos/session_manager.rb b/lib/workos/session_manager.rb index 239734f6..92dff563 100644 --- a/lib/workos/session_manager.rb +++ b/lib/workos/session_manager.rb @@ -181,7 +181,8 @@ def decode_jwt(access_token, verify_expiration: true) algorithms: JWK_ALGORITHMS, jwks: jwks, verify_aud: false, - verify_expiration: verify_expiration + verify_expiration: verify_expiration, + required_claims: ["exp"] ).first end From 060ee3533355412ae500e3a9a596b49410f355d0 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:37:50 -0700 Subject: [PATCH 03/11] fix(session): persist seal_data before decoding refreshed access token Reorder Session#refresh so @seal_data and @cookie_password are updated to the freshly-sealed cookie BEFORE decode_jwt runs. Previously a transient JWKS fetch error or any decode failure on the freshly-minted token left the Session pinned to the rotated (now-revoked) refresh token, leaving the user unable to authenticate until they re-logged in. Source user/impersonator/organization_id from the auth-response payload directly so we never rely on re-decoding the freshly-minted JWT for those fields. The remaining JWT-only claims (sid/role/permissions/etc.) still come from the decode, but a decode failure no longer corrupts session state. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/session.rb | 13 ++++++++----- test/workos/test_session.rb | 10 +++++++--- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 0583ec16..c4f9f257 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -105,17 +105,20 @@ def refresh(organization_id: nil, cookie_password: nil) impersonator: auth_response["impersonator"] ) - # Decode before mutating session state so a malformed access_token - # doesn't leave the Session half-updated. - decoded = @manager.decode_jwt(auth_response["access_token"]) - + # Persist the new seal/password BEFORE decoding the JWT, so a transient + # JWKS fetch error (or any decode failure on the freshly-minted token) + # leaves the Session with a usable sealed cookie that the caller can + # re-#authenticate against, rather than half-updated state. @seal_data = sealed @cookie_password = effective_password + + decoded = @manager.decode_jwt(auth_response["access_token"]) + SessionManager::RefreshSuccess.new( authenticated: true, sealed_session: sealed, session_id: decoded["sid"], - organization_id: decoded["org_id"], + organization_id: auth_response["organization_id"] || decoded["org_id"], role: decoded["role"], roles: decoded["roles"], permissions: decoded["permissions"], diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index 21988887..fcdd3936 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -361,7 +361,7 @@ def test_refresh_does_not_send_session_param_to_api assert_requested(stub) end - def test_refresh_returns_error_on_malformed_access_token_without_mutating_state + def test_refresh_persists_seal_data_even_when_access_token_decode_fails rsa, pub = signing_key_pair old_access = make_jwt({"sid" => "session_old", "exp" => Time.now.to_i - 60}, rsa) sealed = @sm.seal_data({"access_token" => old_access, "refresh_token" => "rt_old", "user" => {"id" => "u_1"}}, PASSWORD) @@ -383,8 +383,12 @@ def test_refresh_returns_error_on_malformed_access_token_without_mutating_state assert_kind_of WorkOS::SessionManager::RefreshError, result refute result.authenticated - # Session state should not have been mutated - assert_equal sealed, session.seal_data + # Session state IS updated to the freshly-sealed cookie before decode runs, + # so a transient JWT/JWKS failure leaves a usable seal the caller can + # re-#authenticate against rather than half-updated state pinned to the + # stale (already-rotated) refresh token. + refute_equal sealed, session.seal_data + refute_nil session.seal_data end # --- Session constructor validation --------------------------------------- From 072908a7ee1310805af71a4df85202e62569c078 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:40:33 -0700 Subject: [PATCH 04/11] fix(session): require cookie_password.bytesize >= 32 at every entry point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reject any cookie_password shorter than 32 bytes (including nil/empty) at: - Session#initialize - SessionManager#seal_data / #unseal_data / #seal_session_from_auth_response (via a new validate_cookie_password! helper) - Encryptors::AesGcm#seal / #unseal (defense in depth for BYO encryptor callers — also normalises the previous nil-key NoMethodError into a proper ArgumentError) The AES-256-GCM key is derived from the password via single-pass SHA-256; a passphrase shorter than the 32-byte digest provides less than the full keyspace and makes offline brute-force feasible. The KDF swap (PBKDF2 / Argon2) is explicitly deferred — it would invalidate live sealed cookies. OPERATIONALLY LOUD: any deployment whose WORKOS cookie_password is shorter than 32 bytes will start raising ArgumentError at SDK init / on the next sealed-session request. There is no flag to opt out by design; the previous behavior silently weakened session-cookie confidentiality. Documented in README and V7_MIGRATION_GUIDE with a one-liner for generating a 32-byte secret via SecureRandom. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 19 +++++++++++++++++++ docs/V7_MIGRATION_GUIDE.md | 21 +++++++++++++++++++++ lib/workos/encryptors/aes_gcm.rb | 24 +++++++++++++++++++----- lib/workos/session.rb | 8 ++++++++ lib/workos/session_manager.rb | 16 +++++++++++++--- test/workos/test_encryptors_aes_gcm.rb | 17 ++++++++++++++++- test/workos/test_session.rb | 18 +++++++++++++++++- 7 files changed, 113 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 66b81300..8365ac0f 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,25 @@ user = WorkOS.client.user_management.create_user( puts user.id ``` +### Sealed sessions (cookie_password requirements) + +When you use `client.session_manager` to seal session cookies, the +`cookie_password` you supply must be **at least 32 bytes** of high-entropy +secret material (typically 32 random bytes encoded as base64 or a 64-char +hex string). The SDK derives the AES-256-GCM key from this password via +SHA-256, and a passphrase shorter than 32 bytes makes the resulting key +materially easier to brute-force offline. + +Generate a suitable secret once and store it as an environment variable: + +```sh +ruby -rsecurerandom -e 'puts SecureRandom.base64(32)' +``` + +Anything shorter than 32 bytes (including `nil` or `""`) raises +`ArgumentError` at SDK init time — sealing or unsealing will not silently +proceed with a weakened key. + ### Verify a webhook ```ruby diff --git a/docs/V7_MIGRATION_GUIDE.md b/docs/V7_MIGRATION_GUIDE.md index ed9e1873..11166fb7 100644 --- a/docs/V7_MIGRATION_GUIDE.md +++ b/docs/V7_MIGRATION_GUIDE.md @@ -501,6 +501,27 @@ Session management was one of the largest refactors in v7. The old `WorkOS::Sess 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. +#### `cookie_password` minimum length (32 bytes) + +v7 enforces a **minimum 32-byte length** on every `cookie_password` you supply +to the session manager (`load`, `seal_data`, `unseal_data`, +`seal_session_from_auth_response`, and the underlying `Encryptors::AesGcm`). + +Anything shorter — including `nil` or `""` — now raises `ArgumentError` at the +moment the SDK is asked to seal or unseal. Older deployments that used a +short passphrase (e.g. a 16-character secret) will start erroring at app +boot or the next sealed-session request. + +Pick a 32+ byte secret once and store it as an environment variable: + +```sh +ruby -rsecurerandom -e 'puts SecureRandom.base64(32)' +``` + +The KDF itself (single-pass SHA-256) is unchanged in this release, so +existing sealed cookies continue to round-trip as long as the same +(now-length-validated) password is in use. + #### Sealing a cookie from an authentication response In v6, you asked `authenticate_with_*` to seal the cookie for you: diff --git a/lib/workos/encryptors/aes_gcm.rb b/lib/workos/encryptors/aes_gcm.rb index 82ac9bcf..451c7918 100644 --- a/lib/workos/encryptors/aes_gcm.rb +++ b/lib/workos/encryptors/aes_gcm.rb @@ -14,8 +14,14 @@ module WorkOS module Encryptors class AesGcm SEAL_VERSION = 0x01 + # Minimum cookie_password byte length. AES-256-GCM derives a 32-byte + # key from the password via SHA-256; a passphrase shorter than the + # output it derives to provides less than the full keyspace and makes + # offline brute-force feasible. See README + V7_MIGRATION_GUIDE.md. + MIN_KEY_BYTES = 32 def seal(data, key) + validate_key!(key) json = data.is_a?(String) ? data : JSON.generate(data) cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt cipher.key = derive_key(key) @@ -26,13 +32,16 @@ def seal(data, key) end def unseal(sealed, key) + validate_key!(key) raw = Base64.decode64(sealed.to_s) - decode_v7(raw, key) - rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error begin - decode_old(raw, key) - rescue ArgumentError, OpenSSL::Cipher::CipherError - raise original_error + decode_v7(raw, key) + rescue ArgumentError, OpenSSL::Cipher::CipherError => original_error + begin + decode_old(raw, key) + rescue ArgumentError, OpenSSL::Cipher::CipherError + raise original_error + end end end @@ -83,6 +92,11 @@ def parse_decoded(decoded) def derive_key(passphrase) Digest::SHA256.digest(passphrase.to_s) end + + def validate_key!(key) + raise ArgumentError, "cookie_password is required" if key.nil? || key.to_s.empty? + raise ArgumentError, "cookie_password must be at least #{MIN_KEY_BYTES} bytes" if key.to_s.bytesize < MIN_KEY_BYTES + end end end end diff --git a/lib/workos/session.rb b/lib/workos/session.rb index c4f9f257..f9b01f57 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -21,8 +21,16 @@ module WorkOS # @example Build a logout URL # url = session.get_logout_url(return_to: "https://app.example.com") class Session + # Minimum cookie_password byte length. AES-256-GCM derives a 32-byte + # key from the password via SHA-256; a passphrase shorter than the + # output it derives to provides less than the full keyspace and makes + # offline brute-force feasible. Require callers to supply at least 32 + # bytes of high-entropy secret. See README + V7_MIGRATION_GUIDE.md. + MIN_COOKIE_PASSWORD_BYTES = 32 + def initialize(manager, seal_data:, cookie_password:) raise ArgumentError, "cookie_password is required" if cookie_password.nil? || cookie_password.empty? + raise ArgumentError, "cookie_password must be at least #{MIN_COOKIE_PASSWORD_BYTES} bytes" if cookie_password.bytesize < MIN_COOKIE_PASSWORD_BYTES @manager = manager @client = manager.client @seal_data = seal_data diff --git a/lib/workos/session_manager.rb b/lib/workos/session_manager.rb index 92dff563..bcf5ce79 100644 --- a/lib/workos/session_manager.rb +++ b/lib/workos/session_manager.rb @@ -150,20 +150,20 @@ def refresh(seal_data:, cookie_password:, organization_id: nil) # H06 — Raw seal: encrypt arbitrary data with a key string. # Delegates to the configured encryptor (default: AES-256-GCM). def seal_data(data, key) - raise ArgumentError, "cookie_password is required" if key.nil? || key.empty? + validate_cookie_password!(key) @encryptor.seal(data, key) end # H06 — Raw unseal: returns parsed JSON (Hash) or raw string if not JSON. # Delegates to the configured encryptor (default: AES-256-GCM). def unseal_data(sealed, key) - raise ArgumentError, "cookie_password is required" if key.nil? || key.empty? + validate_cookie_password!(key) @encryptor.unseal(sealed, key) end # H07 — Build a sealed session string directly from auth-response fields. def seal_session_from_auth_response(access_token:, refresh_token:, cookie_password:, user: nil, impersonator: nil) - raise ArgumentError, "cookie_password is required" if cookie_password.nil? || cookie_password.empty? + validate_cookie_password!(cookie_password) payload = {"access_token" => access_token, "refresh_token" => refresh_token} payload["user"] = user if user payload["impersonator"] = impersonator if impersonator @@ -186,6 +186,16 @@ def decode_jwt(access_token, verify_expiration: true) ).first end + # Validate a cookie_password is non-empty and at least the minimum + # byte length required by Session::MIN_COOKIE_PASSWORD_BYTES (32). + # Defense-in-depth — Session#initialize enforces the same invariant + # on the load path; this guards the inline #seal_data / #unseal_data / + # #seal_session_from_auth_response entry points. + def validate_cookie_password!(key) + raise ArgumentError, "cookie_password is required" if key.nil? || key.empty? + raise ArgumentError, "cookie_password must be at least #{Session::MIN_COOKIE_PASSWORD_BYTES} bytes" if key.bytesize < Session::MIN_COOKIE_PASSWORD_BYTES + end + # Cached JWKS fetch (5-minute TTL, thread-safe). def fetch_jwks(now: Time.now) @jwks_mutex.synchronize do diff --git a/test/workos/test_encryptors_aes_gcm.rb b/test/workos/test_encryptors_aes_gcm.rb index a518e4c1..24e87280 100644 --- a/test/workos/test_encryptors_aes_gcm.rb +++ b/test/workos/test_encryptors_aes_gcm.rb @@ -28,11 +28,26 @@ def test_seal_unseal_round_trip_string def test_unseal_with_wrong_key_raises sealed = @enc.seal({"x" => 1}, PASSWORD) + # Wrong key is the same length (>= 32 bytes) so the length guard doesn't + # short-circuit; we want to assert the underlying cipher rejection. assert_raises(OpenSSL::Cipher::CipherError) do - @enc.unseal(sealed, "wrong-password") + @enc.unseal(sealed, "wrong-cookie-password-32-bytes--") end end + def test_unseal_rejects_short_key + sealed = @enc.seal({"x" => 1}, PASSWORD) + assert_raises(ArgumentError) do + @enc.unseal(sealed, "too-short") + end + end + + def test_seal_rejects_short_key + assert_raises(ArgumentError) { @enc.seal({"x" => 1}, "too-short") } + assert_raises(ArgumentError) { @enc.seal({"x" => 1}, nil) } + assert_raises(ArgumentError) { @enc.seal({"x" => 1}, "") } + end + def test_unseal_rejects_short_payload assert_raises(ArgumentError) do @enc.unseal(Base64.strict_encode64("short"), PASSWORD) diff --git a/test/workos/test_session.rb b/test/workos/test_session.rb index fcdd3936..fdd1c6da 100644 --- a/test/workos/test_session.rb +++ b/test/workos/test_session.rb @@ -26,11 +26,27 @@ def test_seal_then_unseal_round_trip_hash def test_unseal_with_wrong_key_raises sealed = @sm.seal_data({"x" => 1}, PASSWORD) + # Wrong key is the same length (>= 32 bytes) so the length guard doesn't + # short-circuit; we want to assert the underlying cipher rejection. assert_raises(OpenSSL::Cipher::CipherError) do - @sm.unseal_data(sealed, "wrong-password") + @sm.unseal_data(sealed, "wrong-cookie-password-32-bytes--") end end + def test_unseal_with_short_key_raises_argument_error + sealed = @sm.seal_data({"x" => 1}, PASSWORD) + assert_raises(ArgumentError) { @sm.unseal_data(sealed, "too-short") } + end + + def test_seal_with_short_key_raises_argument_error + assert_raises(ArgumentError) { @sm.seal_data({"x" => 1}, "too-short") } + end + + def test_session_load_requires_min_length_cookie_password + short = "x" * 31 + assert_raises(ArgumentError) { @sm.load(seal_data: "x", cookie_password: short) } + end + def test_unseal_rejects_short_payload assert_raises(ArgumentError) do @sm.unseal_data(Base64.strict_encode64("short"), PASSWORD) From 4181faff200dc9cec01fc2d9bc789ab01a41fbec Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:42:12 -0700 Subject: [PATCH 05/11] fix(base_client): redact bearer-token path segments before logging The base client previously interpolated request.path verbatim into every log line (:debug request start, :info request retry, :warn request error, :warn connection error). For paths whose segments carry bearer-equivalent material (invitation by_token, magic_auth, password reset, email verification, sessions/authorize|logout) this leaks the token to anyone with log access when verbose logging is enabled. Add a private redact_path helper and route every request.path log site through it. Generated services pass the unmodified path to Net::HTTP, so the wire request is unchanged; only the hand-written log path is redacted. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/base_client.rb | 48 +++++++++++++++++++++++++++++---- test/workos/test_base_client.rb | 24 +++++++++++++++++ 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index adbe516f..7b7c8222 100644 --- a/lib/workos/base_client.rb +++ b/lib/workos/base_client.rb @@ -134,7 +134,8 @@ def execute_request(request:, request_options: nil) attempt = 0 loop do - log(:debug, "request start", method: request.method, path: request.path, attempt: attempt + 1) + loggable_path = redact_path(request.path) + log(:debug, "request start", method: request.method, path: loggable_path, attempt: attempt + 1) http = connection_for(base, timeout) response = http.request(request) return response if response.is_a?(Net::HTTPSuccess) @@ -142,11 +143,11 @@ def execute_request(request:, request_options: nil) if attempt < retries && retryable?(response) attempt += 1 inject_retry_idempotency_key(request) - log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, status: response.code.to_i) + log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, status: response.code.to_i) sleep(retry_delay(response, attempt)) next end - 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"]) + 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"]) handle_error_response(response) rescue Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, Errno::ECONNREFUSED, @@ -155,11 +156,11 @@ def execute_request(request:, request_options: nil) if attempt < retries attempt += 1 inject_retry_idempotency_key(request) - log(:info, "request retry", method: request.method, path: request.path, attempt: attempt + 1, error: e.class.name) + log(:info, "request retry", method: request.method, path: loggable_path, attempt: attempt + 1, error: e.class.name) sleep(retry_delay(nil, attempt)) next end - log(:warn, "connection error", method: request.method, path: request.path, error: e.class.name, message: e.message) + log(:warn, "connection error", method: request.method, path: loggable_path, error: e.class.name, message: e.message) raise WorkOS::APIConnectionError.new(message: e.message) end end @@ -179,6 +180,43 @@ def shutdown private + # Redact path segments that carry bearer-equivalent tokens (e.g. + # `/user_management/invitations/by_token/`, + # `/user_management/magic_auth/`, password-reset / email- + # verification token paths) before the path is written to a logger. + # The WorkOS API exposes a small number of "by_token" endpoints whose + # path segments are themselves authentication material; redacting them + # here means the SDK never emits the token in its own log/retry/error + # messages even when the host application configures verbose logging. + REDACTED_TOKEN_PREFIXES = %w[ + /user_management/invitations/by_token + /user_management/magic_auth + /user_management/password_reset + /user_management/email_verification + /user_management/sessions/authorize + /user_management/sessions/logout + ].freeze + private_constant :REDACTED_TOKEN_PREFIXES + + def redact_path(path) + return path if path.nil? || path.empty? + + # Strip query string for the prefix match; reattach unmodified after. + path_only, query = path.split("?", 2) + REDACTED_TOKEN_PREFIXES.each do |prefix| + next unless path_only.start_with?("#{prefix}/") + + # Replace every segment after the matched prefix with "[REDACTED]". + remainder = path_only[(prefix.length + 1)..] + next if remainder.nil? || remainder.empty? + + redacted = remainder.split("/").map { "[REDACTED]" }.join("/") + path_only = "#{prefix}/#{redacted}" + break + end + query ? "#{path_only}?#{query}" : path_only + end + def append_query(path, params) return path unless params.is_a?(Hash) && !params.empty? diff --git a/test/workos/test_base_client.rb b/test/workos/test_base_client.rb index 1d6f338e..bcae2127 100644 --- a/test/workos/test_base_client.rb +++ b/test/workos/test_base_client.rb @@ -170,4 +170,28 @@ def test_evict_connection_removes_matching_pooled_connections assert evict.finished refute keep.finished end + + def test_redact_path_strips_invitation_token_segment + redacted = @client.send(:redact_path, "/user_management/invitations/by_token/invtoken_secret123") + assert_equal "/user_management/invitations/by_token/[REDACTED]", redacted + end + + def test_redact_path_strips_magic_auth_token_segment + redacted = @client.send(:redact_path, "/user_management/magic_auth/magic_secret/extra") + assert_equal "/user_management/magic_auth/[REDACTED]/[REDACTED]", redacted + end + + def test_redact_path_preserves_non_token_paths + assert_equal "/organizations/org_123", @client.send(:redact_path, "/organizations/org_123") + end + + def test_redact_path_preserves_query_string + redacted = @client.send(:redact_path, "/user_management/invitations/by_token/secret?foo=bar") + assert_equal "/user_management/invitations/by_token/[REDACTED]?foo=bar", redacted + end + + def test_redact_path_handles_nil_and_empty + assert_nil @client.send(:redact_path, nil) + assert_equal "", @client.send(:redact_path, "") + end end From 5cff2d1d6877cf9f0c7ece33a8b23f28300ec425 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:42:27 -0700 Subject: [PATCH 06/11] fix(actions): use symmetric tolerance check for verify_header timestamp Replace (Time.now.to_f - issued_at) > tolerance with .abs so an event whose timestamp is far in the future (e.g. clock skew, attacker-supplied header) is rejected just like one too far in the past. Matches the same fix in webhooks#verify_header. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/actions.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/workos/actions.rb b/lib/workos/actions.rb index 6113e7a9..ed145a79 100644 --- a/lib/workos/actions.rb +++ b/lib/workos/actions.rb @@ -35,7 +35,7 @@ module Actions def verify_header(payload:, sig_header:, secret:, tolerance: DEFAULT_TOLERANCE_SECONDS) timestamp_ms, signature_hash = parse_signature_header(sig_header) issued_at = timestamp_ms.to_i / 1000.0 - if (Time.now.to_f - issued_at) > tolerance + if (Time.now.to_f - issued_at).abs > tolerance raise WorkOS::SignatureVerificationError.new( message: "Timestamp outside the tolerance zone", http_status: nil From cc65ed7664a0100f600075c80691baa95586c356 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:42:43 -0700 Subject: [PATCH 07/11] fix(webhooks): use symmetric tolerance check for verify_header timestamp Replace (Time.now.to_f - issued_at) > max_age with .abs so a future-dated event (clock skew or attacker-supplied header) is rejected just like a stale one. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/webhooks.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/workos/webhooks.rb b/lib/workos/webhooks.rb index c4b6dba1..1b77bc19 100644 --- a/lib/workos/webhooks.rb +++ b/lib/workos/webhooks.rb @@ -193,7 +193,7 @@ def verify_header(payload:, sig_header:, secret:, tolerance: DEFAULT_TOLERANCE_S timestamp_ms, signature_hash = parse_signature_header(sig_header) max_age = tolerance.to_i issued_at = timestamp_ms.to_i / 1000.0 - if (Time.now.to_f - issued_at) > max_age + if (Time.now.to_f - issued_at).abs > max_age raise WorkOS::SignatureVerificationError.new( message: "Timestamp outside the tolerance zone", http_status: nil From a0fe5644b2394049fd9bea5061c31d6e14f96643 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:43:06 -0700 Subject: [PATCH 08/11] fix(user_management): strip caller-supplied PKCE params in get_authorization_url_with_pkce The helper exists specifically to generate code_challenge / code_challenge_method itself, so a caller-supplied value in **opts would either silently override the freshly-generated challenge (defeating the helper and decoupling the returned code_verifier from the issued URL) or collide with the explicit keyword args below and raise ArgumentError. Mirror the existing opts.delete(:state) pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/user_management.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lib/workos/user_management.rb b/lib/workos/user_management.rb index 4916649c..d6a7e012 100644 --- a/lib/workos/user_management.rb +++ b/lib/workos/user_management.rb @@ -1641,6 +1641,13 @@ def get_authorization_url(redirect_uri:, client_id: nil, provider: nil, connecti def get_authorization_url_with_pkce(redirect_uri:, client_id: nil, **opts) pair = WorkOS::PKCE.generate_pair state = opts.delete(:state) || WorkOS::PKCE.generate_code_verifier + # Strip caller-supplied PKCE params: this helper exists specifically + # to generate them, so a caller-provided value would either silently + # override our freshly-generated challenge (defeating the helper) or + # collide with the keyword args below and raise. Mirror the existing + # opts.delete(:state) pattern. + opts.delete(:code_challenge) + opts.delete(:code_challenge_method) url = get_authorization_url( redirect_uri: redirect_uri, client_id: client_id, From 6c2a75fca2d3cf3b4b3ff5e02d225f29b23fd61f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 12:24:13 -0700 Subject: [PATCH 09/11] fix(review): drop dead redact_path entries and unreachable exp.nil? branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REDACTED_TOKEN_PREFIXES listed /user_management/sessions/authorize and /user_management/sessions/logout, but those URLs are built client-side by UserManagement#get_logout_url / the OAuth authorize-URL helper and never flow through BaseClient#execute, so redact_path is never invoked for them. Even if they were, the URLs carry their identifiers as query parameters, not path segments, and the start_with?("#{prefix}/") guard requires a trailing path segment. Remove the two dead entries — the overstated coverage in the prior commit body did not match the wire. In Session#authenticate, decode_jwt now passes required_claims: ["exp"], so a token missing the claim raises JWT::MissingRequiredClaim (a JWT::DecodeError subclass) and is caught by the existing rescue. The decoded["exp"].nil? half of the is_expired guard is therefore unreachable; drop it so future readers aren't misled about when exp can be absent. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/base_client.rb | 2 -- lib/workos/session.rb | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/workos/base_client.rb b/lib/workos/base_client.rb index 7b7c8222..e1788235 100644 --- a/lib/workos/base_client.rb +++ b/lib/workos/base_client.rb @@ -193,8 +193,6 @@ def shutdown /user_management/magic_auth /user_management/password_reset /user_management/email_verification - /user_management/sessions/authorize - /user_management/sessions/logout ].freeze private_constant :REDACTED_TOKEN_PREFIXES diff --git a/lib/workos/session.rb b/lib/workos/session.rb index f9b01f57..58ca3780 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -65,7 +65,7 @@ def authenticate(include_expired: false, &claim_extractor) return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT) end - is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i + is_expired = decoded["exp"] < Time.now.to_i SessionManager::AuthSuccess.new( authenticated: !is_expired, From 9ce069f647e7930b59b10a5e4eeba4be5a60ec5b Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 12:43:15 -0700 Subject: [PATCH 10/11] revert(session): drop required_claims: ['exp'] format-tightening on JWT decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier in this branch I added required_claims: ['exp'] to JWT.decode (commit a48a195) and then removed the now-redundant decoded['exp'].nil? guard (commit 6c2a75f) on the assumption it was dead code. Reverting both: required_claims makes the Ruby SDK strictly more demanding than its sister SDKs (workos-node's jose call passes no required-claims; workos-php's exp check is `isset($exp) && $exp < time()` — both accept exp-less tokens). This is the same parity argument I used on workos-php#386 to defer iss/aud validation; applying it consistently means I shouldn't have unilaterally tightened exp here either. WorkOS-issued access tokens always carry exp, so the practical impact on real callers is nil — but the reason-code shift (INVALID_JWT vs EXPIRED_JWT for the exp-less edge case) and cross-SDK divergence are both observable, and the defense-in-depth value is near zero (forging an exp-less token requires WorkOS's signing key). Restore the `decoded['exp'].nil? ||` half of the is_expired guard so an exp-less token still surfaces as expired through Session#authenticate rather than crashing on `nil < Time.now.to_i`. JWT-claim hardening (iss/aud/exp) will be revisited as a coordinated cross-SDK change with the auth team. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/workos/session.rb | 2 +- lib/workos/session_manager.rb | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/workos/session.rb b/lib/workos/session.rb index 58ca3780..f9b01f57 100644 --- a/lib/workos/session.rb +++ b/lib/workos/session.rb @@ -65,7 +65,7 @@ def authenticate(include_expired: false, &claim_extractor) return SessionManager::AuthError.new(authenticated: false, reason: SessionManager::INVALID_JWT) end - is_expired = decoded["exp"] < Time.now.to_i + is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i SessionManager::AuthSuccess.new( authenticated: !is_expired, diff --git a/lib/workos/session_manager.rb b/lib/workos/session_manager.rb index bcf5ce79..aad0b354 100644 --- a/lib/workos/session_manager.rb +++ b/lib/workos/session_manager.rb @@ -181,8 +181,7 @@ def decode_jwt(access_token, verify_expiration: true) algorithms: JWK_ALGORITHMS, jwks: jwks, verify_aud: false, - verify_expiration: verify_expiration, - required_claims: ["exp"] + verify_expiration: verify_expiration ).first end From 2c6ecd09a7b25e2ac5b5cd054fe355bc5c208268 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 13:04:50 -0700 Subject: [PATCH 11/11] test(webhooks,actions): cover future-dated timestamp rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing verify_header tests only exercised the stale-past direction, so the asymmetric `(now - issued_at) > tolerance` bug fixed in cc65ed7 and 5cff2d1 wouldn't have been caught by the suite. Add the future-dated direction (10 min ahead for webhooks, 60s ahead for Actions — beyond the default 30s tolerance) so the symmetric `.abs` check is locked in. Closes the regression-coverage gap called out in the security finding that drove the original .abs fix. Co-Authored-By: Claude Opus 4.7 (1M context) --- test/workos/test_actions.rb | 9 +++++++++ test/workos/test_webhook_verify.rb | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/test/workos/test_actions.rb b/test/workos/test_actions.rb index a87c115c..2481fde6 100644 --- a/test/workos/test_actions.rb +++ b/test/workos/test_actions.rb @@ -49,6 +49,15 @@ def test_verify_header_uses_30s_default_tolerance end end + def test_verify_header_raises_on_future_timestamp + payload = '{"x":1}' + future_ts = now_ms + 60_000 # 60s ahead, beyond default 30s tolerance + sig = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{future_ts}.#{payload}") + assert_raises(WorkOS::SignatureVerificationError) do + @actions.verify_header(payload: payload, sig_header: "t=#{future_ts}, v1=#{sig}", secret: SECRET) + end + end + def test_sign_response_authentication_allow resp = @actions.sign_response(action_type: "authentication", verdict: "Allow", secret: SECRET) assert_equal "authentication_action_response", resp["object"] diff --git a/test/workos/test_webhook_verify.rb b/test/workos/test_webhook_verify.rb index e273b87a..b1e082e2 100644 --- a/test/workos/test_webhook_verify.rb +++ b/test/workos/test_webhook_verify.rb @@ -60,6 +60,17 @@ def test_verify_header_raises_on_stale_timestamp assert_match(/Timestamp outside the tolerance zone/, err.message) end + def test_verify_header_raises_on_future_timestamp + payload = '{"x":1}' + future_ts = now_ms + (10 * 60 * 1000) # 10 minutes ahead + sig = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{future_ts}.#{payload}") + header = "t=#{future_ts}, v1=#{sig}" + err = assert_raises(WorkOS::SignatureVerificationError) do + @webhooks.verify_header(payload: payload, sig_header: header, secret: SECRET, tolerance: 60) + end + assert_match(/Timestamp outside the tolerance zone/, err.message) + end + def test_verify_header_raises_on_malformed_header assert_raises(WorkOS::SignatureVerificationError) do @webhooks.verify_header(payload: "{}", sig_header: "garbage", secret: SECRET)