From a306facab5bc6174ea342effcd0aaef2c532774e Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:35:42 -0700 Subject: [PATCH 01/12] fix: percent-encode path segments in base client Centralize path-segment encoding so user-supplied identifiers cannot escape their intended URL segment via reserved characters such as '/', '?', '#', or '%'. A new helper `_encode_path` splits on '/' and applies `urllib.parse.quote(seg, safe='')` to each segment, and is invoked from both the sync and async `request` methods. This covers all ~115 generated call sites without per-site changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/_base_client.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index 83c18d39..9153a6dd 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -10,6 +10,7 @@ from datetime import datetime, timezone from email.utils import parsedate_to_datetime from typing import Any, Dict, Optional, Type, cast, overload +from urllib.parse import quote import httpx @@ -128,6 +129,21 @@ def _resolve_base_url(self, request_options: Optional[RequestOptions]) -> str: return str(base_url).rstrip("/") return self._base_url.rstrip("/") + @staticmethod + def _encode_path(path: str) -> str: + """Percent-encode each path segment to prevent path-traversal/injection. + + Splits on ``/`` and applies ``urllib.parse.quote(seg, safe='')`` to each + segment so that user-supplied IDs containing reserved characters (``/``, + ``?``, ``#``, ``%``, etc.) cannot escape their intended segment. The + leading slash (if any) is preserved. + """ + if not path: + return path + leading = "/" if path.startswith("/") else "" + body = path[1:] if leading else path + return leading + "/".join(quote(seg, safe="") for seg in body.split("/")) + def _resolve_timeout(self, request_options: Optional[RequestOptions]) -> float: timeout = self._request_timeout if request_options: @@ -406,7 +422,7 @@ def request( request_options: Optional[RequestOptions] = None, ) -> Any: """Make an HTTP request with retry logic.""" - url = f"{self._resolve_base_url(request_options)}/{path}" + url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path).lstrip('/')}" headers = self._build_headers(method, idempotency_key, request_options) timeout = self._resolve_timeout(request_options) max_retries = self._resolve_max_retries(request_options) @@ -631,7 +647,7 @@ async def request( request_options: Optional[RequestOptions] = None, ) -> Any: """Make an async HTTP request with retry logic.""" - url = f"{self._resolve_base_url(request_options)}/{path}" + url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path).lstrip('/')}" headers = self._build_headers(method, idempotency_key, request_options) timeout = self._resolve_timeout(request_options) max_retries = self._resolve_max_retries(request_options) From 380434bd35f5e16fdb51ed003def4dcac862b362 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:36:51 -0700 Subject: [PATCH 02/12] fix: add structured failure reasons for session refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds new members to `AuthenticateWithSessionCookieFailureReason` (`MFA_CHALLENGE_REQUIRED`, `SSO_REQUIRED`, `EMAIL_VERIFICATION_REQUIRED`, `ORGANIZATION_SELECTION_REQUIRED`, `REFRESH_DENIED`, `REFRESH_NETWORK_ERROR`) and maps the relevant auth-flow / network exceptions raised during refresh to those reasons instead of stringifying the exception. The `reason` field is already typed `Union[..., str]`, so existing string-based consumers keep working — additive only. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/session.py | 43 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/workos/session.py b/src/workos/session.py index fbee198e..e87975c9 100644 --- a/src/workos/session.py +++ b/src/workos/session.py @@ -24,6 +24,16 @@ from cryptography.fernet import Fernet from jwt import PyJWKClient +from ._errors import ( + AuthenticationError, + EmailVerificationRequiredError, + MfaChallengeError, + OrganizationSelectionRequiredError, + SsoRequiredError, + WorkOSConnectionError, + WorkOSTimeoutError, +) + if TYPE_CHECKING: from ._client import AsyncWorkOSClient, WorkOSClient @@ -37,6 +47,35 @@ class AuthenticateWithSessionCookieFailureReason(Enum): INVALID_JWT = "invalid_jwt" INVALID_SESSION_COOKIE = "invalid_session_cookie" NO_SESSION_COOKIE_PROVIDED = "no_session_cookie_provided" + MFA_CHALLENGE_REQUIRED = "mfa_challenge_required" + SSO_REQUIRED = "sso_required" + EMAIL_VERIFICATION_REQUIRED = "email_verification_required" + ORGANIZATION_SELECTION_REQUIRED = "organization_selection_required" + REFRESH_DENIED = "refresh_denied" + REFRESH_NETWORK_ERROR = "refresh_network_error" + + +def _map_refresh_exception_to_reason( + exc: Exception, +) -> Union[AuthenticateWithSessionCookieFailureReason, str]: + """Map an exception raised by a refresh request to a structured reason. + + Falls back to ``str(exc)`` for unknown errors so callers retain the + pre-existing string form for diagnostics. + """ + if isinstance(exc, MfaChallengeError): + return AuthenticateWithSessionCookieFailureReason.MFA_CHALLENGE_REQUIRED + if isinstance(exc, SsoRequiredError): + return AuthenticateWithSessionCookieFailureReason.SSO_REQUIRED + if isinstance(exc, EmailVerificationRequiredError): + return AuthenticateWithSessionCookieFailureReason.EMAIL_VERIFICATION_REQUIRED + if isinstance(exc, OrganizationSelectionRequiredError): + return AuthenticateWithSessionCookieFailureReason.ORGANIZATION_SELECTION_REQUIRED + if isinstance(exc, AuthenticationError): + return AuthenticateWithSessionCookieFailureReason.REFRESH_DENIED + if isinstance(exc, (WorkOSConnectionError, WorkOSTimeoutError)): + return AuthenticateWithSessionCookieFailureReason.REFRESH_NETWORK_ERROR + return str(exc) @dataclass(slots=True) @@ -328,7 +367,7 @@ def refresh( ) except Exception as e: return RefreshWithSessionCookieErrorResponse( - authenticated=False, reason=str(e) + authenticated=False, reason=_map_refresh_exception_to_reason(e) ) def get_logout_url(self, return_to: Optional[str] = None) -> str: @@ -507,7 +546,7 @@ async def refresh( ) except Exception as e: return RefreshWithSessionCookieErrorResponse( - authenticated=False, reason=str(e) + authenticated=False, reason=_map_refresh_exception_to_reason(e) ) async def get_logout_url(self, return_to: Optional[str] = None) -> str: From ba796802475edb6cc78ffb9662f3c8136e40d384 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:38:14 -0700 Subject: [PATCH 03/12] fix: force public clients to drop the API key `create_public_client` now passes `api_key=None` and `is_public=True` to `WorkOSClient`. The base client honors `is_public` by forcing `_api_key` to `None` and ignoring the `WORKOS_API_KEY` environment variable, so a public/PKCE client cannot accidentally pick up an API key from the process environment. The existing `if self._client._api_key` guards in PKCE token- exchange paths (`sso/_resource.py`, `user_management/_resource.py`) keep `client_secret` out of the request body for public clients without further changes. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/_base_client.py | 17 ++++++++++++++++- src/workos/public_client.py | 2 ++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/workos/_base_client.py b/src/workos/_base_client.py index 9153a6dd..1becc09d 100644 --- a/src/workos/_base_client.py +++ b/src/workos/_base_client.py @@ -54,8 +54,15 @@ def __init__( request_timeout: Optional[int] = None, jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, + is_public: bool = False, ) -> None: - self._api_key = api_key or os.environ.get("WORKOS_API_KEY") + self._is_public = is_public + # Public clients (PKCE / browser / mobile / CLI) must never attach + # an API key, even if WORKOS_API_KEY is present in the environment. + if is_public: + self._api_key: Optional[str] = None + else: + self._api_key = api_key or os.environ.get("WORKOS_API_KEY") self.client_id = client_id or os.environ.get("WORKOS_CLIENT_ID") if not self._api_key and not self.client_id: raise ValueError( @@ -348,6 +355,7 @@ def __init__( request_timeout: Optional[int] = None, jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, + is_public: bool = False, ) -> None: """Initialize the WorkOS client. @@ -358,6 +366,10 @@ def __init__( request_timeout: HTTP request timeout in seconds. Falls back to WORKOS_REQUEST_TIMEOUT or 60. jwt_leeway: JWT clock skew leeway in seconds. max_retries: Maximum number of retries for failed requests. Defaults to 3. + is_public: When True, mark this client as public (PKCE / browser + / mobile / CLI). The API key is forced to None and the + ``WORKOS_API_KEY`` environment variable is ignored. Use + ``create_public_client`` instead of setting this directly. Raises: ValueError: If neither api_key nor client_id is provided, directly or via environment variables. @@ -369,6 +381,7 @@ def __init__( request_timeout=request_timeout, jwt_leeway=jwt_leeway, max_retries=max_retries, + is_public=is_public, ) self._client = httpx.Client( timeout=self._request_timeout, follow_redirects=True @@ -573,6 +586,7 @@ def __init__( request_timeout: Optional[int] = None, jwt_leeway: float = 0.0, max_retries: int = MAX_RETRIES, + is_public: bool = False, ) -> None: """Initialize the async WorkOS client. @@ -594,6 +608,7 @@ def __init__( request_timeout=request_timeout, jwt_leeway=jwt_leeway, max_retries=max_retries, + is_public=is_public, ) self._client = httpx.AsyncClient( timeout=self._request_timeout, follow_redirects=True diff --git a/src/workos/public_client.py b/src/workos/public_client.py index 057b7e40..b4bca338 100644 --- a/src/workos/public_client.py +++ b/src/workos/public_client.py @@ -33,7 +33,9 @@ def create_public_client( from ._client import WorkOSClient return WorkOSClient( + api_key=None, client_id=client_id, base_url=base_url, request_timeout=request_timeout, + is_public=True, ) From 7f58e9f7662bf5f9ae7182511fa92014b36d11dd Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:38:38 -0700 Subject: [PATCH 04/12] fix: use symmetric tolerance check for webhook timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the freshness window only rejected timestamps that were older than the tolerance — clock skew that put the issued timestamp in the future was silently accepted, opening the verifier to replay-style attacks once the attacker's clock drifted forward. Compare `abs(seconds_since_issued)` against `max_seconds_since_issued` in both the sync and async `verify_header` implementations. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/webhooks/_resource.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/workos/webhooks/_resource.py b/src/workos/webhooks/_resource.py index 0b0d11fb..be93fa58 100644 --- a/src/workos/webhooks/_resource.py +++ b/src/workos/webhooks/_resource.py @@ -263,7 +263,7 @@ def verify_header( timestamp_in_seconds = int(issued_timestamp) / 1000 seconds_since_issued = current_time - timestamp_in_seconds - if seconds_since_issued > max_seconds_since_issued: + if abs(seconds_since_issued) > max_seconds_since_issued: raise ValueError("Timestamp outside the tolerance zone") body_str = ( @@ -520,7 +520,7 @@ def verify_header( timestamp_in_seconds = int(issued_timestamp) / 1000 seconds_since_issued = current_time - timestamp_in_seconds - if seconds_since_issued > max_seconds_since_issued: + if abs(seconds_since_issued) > max_seconds_since_issued: raise ValueError("Timestamp outside the tolerance zone") body_str = ( From 77d7ed2501a25a1f6f048969d5e5761774076810 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:38:57 -0700 Subject: [PATCH 05/12] fix: bound LEB128 decode to 32 bits in vault payload `_decode_u32_leb128` previously kept reading continuation bytes after the 4th byte (5+ continuations could yield a 35+ bit value that would later be used as `key_len` for slicing the payload). Tighten the loop to reject a 5th continuation byte (`i >= 4 and b & 0x80`) and validate the decoded result against the 32-bit ceiling (`> 0xFFFFFFFF`) before returning. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/vault.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/workos/vault.py b/src/workos/vault.py index b5d50895..06740d2e 100644 --- a/src/workos/vault.py +++ b/src/workos/vault.py @@ -282,10 +282,12 @@ def _decode_u32_leb128(buf: bytes) -> Tuple[int, int]: res = 0 bit = 0 for i, b in enumerate(buf): - if i > 4: + if i >= 4 and (b & 0x80) != 0: raise ValueError("LEB128 integer overflow (was more than 4 bytes)") res |= (b & 0x7F) << (7 * bit) if (b & 0x80) == 0: + if res > 0xFFFFFFFF: + raise ValueError("LEB128 integer overflow (exceeds 32 bits)") return res, i + 1 bit += 1 raise ValueError("LEB128 integer not found") From 0c12462f71fa898891ea43300ba294aed3540dd7 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:39:33 -0700 Subject: [PATCH 06/12] fix: preserve empty AAD in vault crypto helpers Replace the truthy `if aad:` / `if associated_data` guards with explicit `is not None` checks in `_aes_gcm_encrypt`, `_aes_gcm_decrypt`, and the four `aad_buffer` construction sites. Previously an empty string passed as `associated_data` (or an empty `bytes` aad) was silently dropped, so encrypt and decrypt paths disagreed on the AAD bound to the GCM tag. Wire-format risk: any locally-persisted vault ciphertext that was encrypted by an earlier version of this SDK with `associated_data=""` (or `aad=b""`) was actually written without AAD. After this fix the SDK now binds an empty AAD, so re-encrypting with the same empty string will produce a tag the old SDK cannot verify, and decrypting old empty-AAD ciphertexts must continue to use no AAD. WorkOS Vault ciphertexts live server-side, so production impact is bounded; applications that have stashed ciphertexts locally must migrate. Document in the next release migration note. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/vault.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/workos/vault.py b/src/workos/vault.py index 06740d2e..832682b1 100644 --- a/src/workos/vault.py +++ b/src/workos/vault.py @@ -240,7 +240,7 @@ def _aes_gcm_encrypt( encryptor = Cipher( algorithms.AES(key), modes.GCM(iv), backend=default_backend() ).encryptor() - if aad: + if aad is not None: encryptor.authenticate_additional_data(aad) ciphertext = encryptor.update(plaintext) + encryptor.finalize() return {"ciphertext": ciphertext, "iv": iv, "tag": encryptor.tag} @@ -256,7 +256,7 @@ def _aes_gcm_decrypt( decryptor = Cipher( algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend() ).decryptor() - if aad: + if aad is not None: decryptor.authenticate_additional_data(aad) return decryptor.update(ciphertext) + decryptor.finalize() @@ -470,7 +470,9 @@ def encrypt( key = base64.b64decode(key_pair.data_key.key) key_blob = base64.b64decode(key_pair.encrypted_keys) prefix_len_buffer = _encode_u32_leb128(len(key_blob)) - aad_buffer = associated_data.encode("utf-8") if associated_data else None + aad_buffer = ( + associated_data.encode("utf-8") if associated_data is not None else None + ) iv = os.urandom(12) result = _aes_gcm_encrypt(data.encode("utf-8"), key, iv, aad_buffer) @@ -492,7 +494,9 @@ def decrypt( data_key = self.decrypt_data_key(keys=decoded.keys) key = base64.b64decode(data_key.key) - aad_buffer = associated_data.encode("utf-8") if associated_data else None + aad_buffer = ( + associated_data.encode("utf-8") if associated_data is not None else None + ) decrypted_bytes = _aes_gcm_decrypt( ciphertext=decoded.ciphertext, @@ -649,7 +653,9 @@ async def encrypt( key = base64.b64decode(key_pair.data_key.key) key_blob = base64.b64decode(key_pair.encrypted_keys) prefix_len_buffer = _encode_u32_leb128(len(key_blob)) - aad_buffer = associated_data.encode("utf-8") if associated_data else None + aad_buffer = ( + associated_data.encode("utf-8") if associated_data is not None else None + ) iv = os.urandom(12) result = _aes_gcm_encrypt(data.encode("utf-8"), key, iv, aad_buffer) @@ -670,7 +676,9 @@ async def decrypt( data_key = await self.decrypt_data_key(keys=decoded.keys) key = base64.b64decode(data_key.key) - aad_buffer = associated_data.encode("utf-8") if associated_data else None + aad_buffer = ( + associated_data.encode("utf-8") if associated_data is not None else None + ) decrypted_bytes = _aes_gcm_decrypt( ciphertext=decoded.ciphertext, From 325aa0ce39bd1ec876afd77669e40dec82256932 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:39:58 -0700 Subject: [PATCH 07/12] fix: treat tolerance=0 as a valid window in webhook verifier `tolerance or DEFAULT_TOLERANCE` silently coerced an explicit `tolerance=0` (caller wants no tolerance) to the default 180-second window. Use `tolerance if tolerance is not None else DEFAULT_TOLERANCE` so callers that ask for a strict zero window get one. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/webhooks/_verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/workos/webhooks/_verification.py b/src/workos/webhooks/_verification.py index 3dc99582..55b1e0d0 100644 --- a/src/workos/webhooks/_verification.py +++ b/src/workos/webhooks/_verification.py @@ -78,7 +78,7 @@ def verify_header( issued_timestamp = issued_timestamp[2:] signature_hash = signature_hash[3:] - max_seconds_since_issued = tolerance or DEFAULT_TOLERANCE + max_seconds_since_issued = tolerance if tolerance is not None else DEFAULT_TOLERANCE current_time = time.time() timestamp_in_seconds = int(issued_timestamp) / 1000 seconds_since_issued = current_time - timestamp_in_seconds From bcb693b90b1cbdf2745c9d39965633816bde49af Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Thu, 7 May 2026 14:54:11 -0700 Subject: [PATCH 08/12] style(session): apply ruff format ruff format --check flagged the ORGANIZATION_SELECTION_REQUIRED return as exceeding line length. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/session.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/workos/session.py b/src/workos/session.py index e87975c9..225b2b6b 100644 --- a/src/workos/session.py +++ b/src/workos/session.py @@ -70,7 +70,9 @@ def _map_refresh_exception_to_reason( if isinstance(exc, EmailVerificationRequiredError): return AuthenticateWithSessionCookieFailureReason.EMAIL_VERIFICATION_REQUIRED if isinstance(exc, OrganizationSelectionRequiredError): - return AuthenticateWithSessionCookieFailureReason.ORGANIZATION_SELECTION_REQUIRED + return ( + AuthenticateWithSessionCookieFailureReason.ORGANIZATION_SELECTION_REQUIRED + ) if isinstance(exc, AuthenticationError): return AuthenticateWithSessionCookieFailureReason.REFRESH_DENIED if isinstance(exc, (WorkOSConnectionError, WorkOSTimeoutError)): From 4cf1bf6a8580b338ad1291f04332f9cb2b9433e1 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 11:49:13 -0700 Subject: [PATCH 09/12] fix: apply symmetric tolerance check to actions and standalone verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 7f58e9f7 closed the future-timestamp replay gap in `webhooks/_resource.verify_header`, but the same vulnerable comparison still lived in `actions._verify_signature` and the standalone `webhooks/_verification.verify_header` helper — both rejected only past-skewed timestamps, silently accepting issued timestamps in the future. Compare `abs(seconds_since_issued)` in both helpers and add regression tests covering the future-timestamp case for actions, the webhook resource, and the standalone verifier. --- src/workos/actions.py | 2 +- src/workos/webhooks/_verification.py | 2 +- tests/test_actions.py | 11 +++++++++++ tests/test_webhook_verification.py | 22 ++++++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/workos/actions.py b/src/workos/actions.py index cd2b3b7f..258d7986 100644 --- a/src/workos/actions.py +++ b/src/workos/actions.py @@ -44,7 +44,7 @@ def _verify_signature( timestamp_in_seconds = int(issued_timestamp) / 1000 seconds_since_issued = current_time - timestamp_in_seconds - if seconds_since_issued > tolerance: + if abs(seconds_since_issued) > tolerance: raise ValueError("Timestamp outside the tolerance zone") body_str = payload.decode("utf-8") if isinstance(payload, bytes) else payload diff --git a/src/workos/webhooks/_verification.py b/src/workos/webhooks/_verification.py index 55b1e0d0..3ec82bef 100644 --- a/src/workos/webhooks/_verification.py +++ b/src/workos/webhooks/_verification.py @@ -83,7 +83,7 @@ def verify_header( timestamp_in_seconds = int(issued_timestamp) / 1000 seconds_since_issued = current_time - timestamp_in_seconds - if seconds_since_issued > max_seconds_since_issued: + if abs(seconds_since_issued) > max_seconds_since_issued: raise ValueError("Timestamp outside the tolerance zone") unhashed_string = "{0}.{1}".format(issued_timestamp, event_body.decode("utf-8")) diff --git a/tests/test_actions.py b/tests/test_actions.py index 1d65d8f3..eb107905 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -63,6 +63,17 @@ def test_verify_header_stale_timestamp(self): tolerance=30, ) + def test_verify_header_future_timestamp(self): + future_ts = int((time.time() + 60) * 1000) + sig = _make_sig_header(SAMPLE_ACTION_PAYLOAD, SECRET, future_ts) + with pytest.raises(ValueError, match="tolerance zone"): + self.actions.verify_header( + payload=SAMPLE_ACTION_PAYLOAD, + sig_header=sig, + secret=SECRET, + tolerance=30, + ) + def test_verify_header_custom_tolerance(self): old_ts = int((time.time() - 10) * 1000) sig = _make_sig_header(SAMPLE_ACTION_PAYLOAD, SECRET, old_ts) diff --git a/tests/test_webhook_verification.py b/tests/test_webhook_verification.py index aaa2ea05..8e228b39 100644 --- a/tests/test_webhook_verification.py +++ b/tests/test_webhook_verification.py @@ -124,6 +124,17 @@ def test_verify_header_stale_timestamp(self, workos): tolerance=180, ) + def test_verify_header_future_timestamp(self, workos): + future_ts = int((time.time() + 300) * 1000) + sig = _make_sig_header(SAMPLE_EVENT, SECRET, future_ts) + with pytest.raises(ValueError, match="tolerance zone"): + workos.webhooks.verify_header( + event_body=SAMPLE_EVENT, + event_signature=sig, + secret=SECRET, + tolerance=180, + ) + class TestStandaloneVerifyEvent: def test_standalone_verify_event(self): @@ -157,3 +168,14 @@ def test_standalone_verify_header_invalid(self): event_signature=sig, secret=SECRET, ) + + def test_standalone_verify_header_future_timestamp(self): + future_ts = int((time.time() + 300) * 1000) + sig = _make_sig_header(SAMPLE_EVENT, SECRET, future_ts) + with pytest.raises(ValueError, match="tolerance zone"): + standalone_verify_header( + event_body=SAMPLE_EVENT.encode("utf-8"), + event_signature=sig, + secret=SECRET, + tolerance=180, + ) From 5a475c19b424d7642b242c6516b0d6c83713573f Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 12:33:09 -0700 Subject: [PATCH 10/12] fix(session): map remaining AuthenticationFlowError subtypes to structured reasons Refresh failures from MfaEnrollmentError, OrganizationAuthMethodsRequiredError, AuthenticationMethodNotAllowedError, and RadarChallengeError previously fell through the AuthenticationError check (they inherit from AuthorizationError) and surfaced as bare strings. Add enum members and isinstance branches so all explicit AuthKit flow outcomes resolve to structured reasons. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/session.py | 18 +++++++++++ tests/test_session.py | 72 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/src/workos/session.py b/src/workos/session.py index 225b2b6b..f289c5eb 100644 --- a/src/workos/session.py +++ b/src/workos/session.py @@ -26,9 +26,13 @@ from ._errors import ( AuthenticationError, + AuthenticationMethodNotAllowedError, EmailVerificationRequiredError, MfaChallengeError, + MfaEnrollmentError, + OrganizationAuthMethodsRequiredError, OrganizationSelectionRequiredError, + RadarChallengeError, SsoRequiredError, WorkOSConnectionError, WorkOSTimeoutError, @@ -48,9 +52,13 @@ class AuthenticateWithSessionCookieFailureReason(Enum): INVALID_SESSION_COOKIE = "invalid_session_cookie" NO_SESSION_COOKIE_PROVIDED = "no_session_cookie_provided" MFA_CHALLENGE_REQUIRED = "mfa_challenge_required" + MFA_ENROLLMENT_REQUIRED = "mfa_enrollment_required" SSO_REQUIRED = "sso_required" EMAIL_VERIFICATION_REQUIRED = "email_verification_required" ORGANIZATION_SELECTION_REQUIRED = "organization_selection_required" + ORGANIZATION_AUTH_METHODS_REQUIRED = "organization_auth_methods_required" + AUTHENTICATION_METHOD_NOT_ALLOWED = "authentication_method_not_allowed" + RADAR_CHALLENGE_REQUIRED = "radar_challenge_required" REFRESH_DENIED = "refresh_denied" REFRESH_NETWORK_ERROR = "refresh_network_error" @@ -65,6 +73,8 @@ def _map_refresh_exception_to_reason( """ if isinstance(exc, MfaChallengeError): return AuthenticateWithSessionCookieFailureReason.MFA_CHALLENGE_REQUIRED + if isinstance(exc, MfaEnrollmentError): + return AuthenticateWithSessionCookieFailureReason.MFA_ENROLLMENT_REQUIRED if isinstance(exc, SsoRequiredError): return AuthenticateWithSessionCookieFailureReason.SSO_REQUIRED if isinstance(exc, EmailVerificationRequiredError): @@ -73,6 +83,14 @@ def _map_refresh_exception_to_reason( return ( AuthenticateWithSessionCookieFailureReason.ORGANIZATION_SELECTION_REQUIRED ) + if isinstance(exc, OrganizationAuthMethodsRequiredError): + return AuthenticateWithSessionCookieFailureReason.ORGANIZATION_AUTH_METHODS_REQUIRED + if isinstance(exc, AuthenticationMethodNotAllowedError): + return ( + AuthenticateWithSessionCookieFailureReason.AUTHENTICATION_METHOD_NOT_ALLOWED + ) + if isinstance(exc, RadarChallengeError): + return AuthenticateWithSessionCookieFailureReason.RADAR_CHALLENGE_REQUIRED if isinstance(exc, AuthenticationError): return AuthenticateWithSessionCookieFailureReason.REFRESH_DENIED if isinstance(exc, (WorkOSConnectionError, WorkOSTimeoutError)): diff --git a/tests/test_session.py b/tests/test_session.py index 3355bfb2..15e44e78 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -7,6 +7,19 @@ from cryptography.hazmat.primitives.asymmetric import rsa from workos import WorkOSClient +from workos._errors import ( + AuthenticationError, + AuthenticationMethodNotAllowedError, + EmailVerificationRequiredError, + MfaChallengeError, + MfaEnrollmentError, + OrganizationAuthMethodsRequiredError, + OrganizationSelectionRequiredError, + RadarChallengeError, + SsoRequiredError, + WorkOSConnectionError, + WorkOSTimeoutError, +) from workos.session import ( AsyncSession, AuthenticateWithSessionCookieErrorResponse, @@ -14,6 +27,7 @@ AuthenticateWithSessionCookieSuccessResponse, RefreshWithSessionCookieErrorResponse, Session, + _map_refresh_exception_to_reason, seal_data, seal_session_from_auth_response, unseal_data, @@ -212,6 +226,64 @@ def test_session_refresh_missing_refresh_token(self): assert isinstance(result, RefreshWithSessionCookieErrorResponse) +class TestMapRefreshExceptionToReason: + @pytest.mark.parametrize( + "exc, expected", + [ + ( + MfaChallengeError("mfa challenge"), + AuthenticateWithSessionCookieFailureReason.MFA_CHALLENGE_REQUIRED, + ), + ( + MfaEnrollmentError("mfa enrollment"), + AuthenticateWithSessionCookieFailureReason.MFA_ENROLLMENT_REQUIRED, + ), + ( + SsoRequiredError("sso required"), + AuthenticateWithSessionCookieFailureReason.SSO_REQUIRED, + ), + ( + EmailVerificationRequiredError("email verification required"), + AuthenticateWithSessionCookieFailureReason.EMAIL_VERIFICATION_REQUIRED, + ), + ( + OrganizationSelectionRequiredError("org selection required"), + AuthenticateWithSessionCookieFailureReason.ORGANIZATION_SELECTION_REQUIRED, + ), + ( + OrganizationAuthMethodsRequiredError("org auth methods required"), + AuthenticateWithSessionCookieFailureReason.ORGANIZATION_AUTH_METHODS_REQUIRED, + ), + ( + AuthenticationMethodNotAllowedError("method not allowed"), + AuthenticateWithSessionCookieFailureReason.AUTHENTICATION_METHOD_NOT_ALLOWED, + ), + ( + RadarChallengeError("radar challenge"), + AuthenticateWithSessionCookieFailureReason.RADAR_CHALLENGE_REQUIRED, + ), + ( + AuthenticationError("unauthorized"), + AuthenticateWithSessionCookieFailureReason.REFRESH_DENIED, + ), + ( + WorkOSConnectionError("connection failed"), + AuthenticateWithSessionCookieFailureReason.REFRESH_NETWORK_ERROR, + ), + ( + WorkOSTimeoutError("timeout"), + AuthenticateWithSessionCookieFailureReason.REFRESH_NETWORK_ERROR, + ), + ], + ) + def test_known_exceptions_map_to_reason(self, exc, expected): + assert _map_refresh_exception_to_reason(exc) == expected + + def test_unknown_exception_falls_back_to_string(self): + result = _map_refresh_exception_to_reason(RuntimeError("boom")) + assert result == "boom" + + @pytest.mark.asyncio class TestAsyncSession: def _mock_jwks(self, public_key): From 79278da2863ad3368c019519909ee959b9a46ce6 Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 12:33:16 -0700 Subject: [PATCH 11/12] test(webhooks): assert tolerance=0 honours zero-second window Regression guard for the standalone _verification.verify_header tolerance handling: a 1-second-old signature with tolerance=0 must be rejected, so reverting to the prior `tolerance or DEFAULT_TOLERANCE` form would fail. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_webhook_verification.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_webhook_verification.py b/tests/test_webhook_verification.py index 8e228b39..ba98d5f1 100644 --- a/tests/test_webhook_verification.py +++ b/tests/test_webhook_verification.py @@ -179,3 +179,14 @@ def test_standalone_verify_header_future_timestamp(self): secret=SECRET, tolerance=180, ) + + def test_standalone_verify_header_tolerance_zero_rejects_old_timestamp(self): + old_ts = int((time.time() - 1) * 1000) + sig = _make_sig_header(SAMPLE_EVENT, SECRET, old_ts) + with pytest.raises(ValueError, match="tolerance zone"): + standalone_verify_header( + event_body=SAMPLE_EVENT.encode("utf-8"), + event_signature=sig, + secret=SECRET, + tolerance=0, + ) From 29c595dea314e2b18b2dda5dbd058f607fcc57aa Mon Sep 17 00:00:00 2001 From: "Garen J. Torikian" Date: Fri, 8 May 2026 12:43:01 -0700 Subject: [PATCH 12/12] Revert "fix: preserve empty AAD in vault crypto helpers" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0c12462f71fa898891ea43300ba294aed3540dd7. The original truthy `if aad:` guard was self-consistent within the SDK (encrypt and decrypt agreed: empty string and None both meant "no AAD"), and matches the convention used by sibling WorkOS SDKs. Switching to `is not None` made `aad=""` semantically distinct from `aad=None`, which is cryptographically more precise but breaks decryption of every vault ciphertext previously encrypted with `associated_data=""` once a caller upgrades — the new SDK calls `authenticate_additional_data(b"")` and the GCM tag (computed without AAD on the encrypt side) will not verify. The threat being closed (a caller passing "" expecting an empty AAD to be authenticated, but silently getting none) is a narrow integrity misperception, not an exploitable hole, and there is no signal anyone in the wild relies on it. The wire-format break is the larger harm. A coordinated cross-SDK change with a versioned ciphertext header is the right path if this ever needs to be revisited. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/workos/vault.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/workos/vault.py b/src/workos/vault.py index 832682b1..06740d2e 100644 --- a/src/workos/vault.py +++ b/src/workos/vault.py @@ -240,7 +240,7 @@ def _aes_gcm_encrypt( encryptor = Cipher( algorithms.AES(key), modes.GCM(iv), backend=default_backend() ).encryptor() - if aad is not None: + if aad: encryptor.authenticate_additional_data(aad) ciphertext = encryptor.update(plaintext) + encryptor.finalize() return {"ciphertext": ciphertext, "iv": iv, "tag": encryptor.tag} @@ -256,7 +256,7 @@ def _aes_gcm_decrypt( decryptor = Cipher( algorithms.AES(key), modes.GCM(iv, tag), backend=default_backend() ).decryptor() - if aad is not None: + if aad: decryptor.authenticate_additional_data(aad) return decryptor.update(ciphertext) + decryptor.finalize() @@ -470,9 +470,7 @@ def encrypt( key = base64.b64decode(key_pair.data_key.key) key_blob = base64.b64decode(key_pair.encrypted_keys) prefix_len_buffer = _encode_u32_leb128(len(key_blob)) - aad_buffer = ( - associated_data.encode("utf-8") if associated_data is not None else None - ) + aad_buffer = associated_data.encode("utf-8") if associated_data else None iv = os.urandom(12) result = _aes_gcm_encrypt(data.encode("utf-8"), key, iv, aad_buffer) @@ -494,9 +492,7 @@ def decrypt( data_key = self.decrypt_data_key(keys=decoded.keys) key = base64.b64decode(data_key.key) - aad_buffer = ( - associated_data.encode("utf-8") if associated_data is not None else None - ) + aad_buffer = associated_data.encode("utf-8") if associated_data else None decrypted_bytes = _aes_gcm_decrypt( ciphertext=decoded.ciphertext, @@ -653,9 +649,7 @@ async def encrypt( key = base64.b64decode(key_pair.data_key.key) key_blob = base64.b64decode(key_pair.encrypted_keys) prefix_len_buffer = _encode_u32_leb128(len(key_blob)) - aad_buffer = ( - associated_data.encode("utf-8") if associated_data is not None else None - ) + aad_buffer = associated_data.encode("utf-8") if associated_data else None iv = os.urandom(12) result = _aes_gcm_encrypt(data.encode("utf-8"), key, iv, aad_buffer) @@ -676,9 +670,7 @@ async def decrypt( data_key = await self.decrypt_data_key(keys=decoded.keys) key = base64.b64decode(data_key.key) - aad_buffer = ( - associated_data.encode("utf-8") if associated_data is not None else None - ) + aad_buffer = associated_data.encode("utf-8") if associated_data else None decrypted_bytes = _aes_gcm_decrypt( ciphertext=decoded.ciphertext,