Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions docs/V7_MIGRATION_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion lib/workos/actions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 41 additions & 5 deletions lib/workos/base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,19 +134,20 @@ 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)

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,
Expand All @@ -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
Expand All @@ -179,6 +180,41 @@ def shutdown

private

# Redact path segments that carry bearer-equivalent tokens (e.g.
# `/user_management/invitations/by_token/<token>`,
# `/user_management/magic_auth/<token>`, 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
].freeze
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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?

Expand Down
24 changes: 19 additions & 5 deletions lib/workos/encryptors/aes_gcm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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

Expand Down Expand Up @@ -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
23 changes: 17 additions & 6 deletions lib/workos/session.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,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"] && decoded["exp"] < Time.now.to_i
is_expired = decoded["exp"].nil? || decoded["exp"] < Time.now.to_i

SessionManager::AuthSuccess.new(
authenticated: !is_expired,
Expand Down Expand Up @@ -105,17 +113,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"],
Expand Down
13 changes: 13 additions & 0 deletions lib/workos/session_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
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)
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)
validate_cookie_password!(cookie_password)
payload = {"access_token" => access_token, "refresh_token" => refresh_token}
payload["user"] = user if user
payload["impersonator"] = impersonator if impersonator
Expand All @@ -182,6 +185,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
Expand Down
7 changes: 7 additions & 0 deletions lib/workos/user_management.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/workos/webhooks.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions test/workos/test_actions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
24 changes: 24 additions & 0 deletions test/workos/test_base_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
17 changes: 16 additions & 1 deletion test/workos/test_encryptors_aes_gcm.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading