Skip to content

Commit d21f3b4

Browse files
gjtorikianclaude
andauthored
fix: Harden webhook, vault, session, and base client paths (#654)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f53dd44 commit d21f3b4

31 files changed

Lines changed: 1020 additions & 488 deletions

File tree

.oagen-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"version": 2,
33
"language": "python",
4-
"generatedAt": "2026-05-06T23:30:58.090Z",
4+
"generatedAt": "2026-05-11T15:56:51.952Z",
55
"files": [
66
"src/workos/_client.py",
77
"src/workos/admin_portal/__init__.py",

src/workos/_base_client.py

Lines changed: 59 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import random
1010
from datetime import datetime, timezone
1111
from email.utils import parsedate_to_datetime
12-
from typing import Any, Dict, Optional, Type, cast, overload
12+
from typing import Any, Dict, Optional, Sequence, Type, cast, overload
13+
from urllib.parse import quote
1314

1415
import httpx
1516

@@ -53,8 +54,15 @@ def __init__(
5354
request_timeout: Optional[int] = None,
5455
jwt_leeway: float = 0.0,
5556
max_retries: int = MAX_RETRIES,
57+
is_public: bool = False,
5658
) -> None:
57-
self._api_key = api_key or os.environ.get("WORKOS_API_KEY")
59+
self._is_public = is_public
60+
# Public clients (PKCE / browser / mobile / CLI) must never attach
61+
# an API key, even if WORKOS_API_KEY is present in the environment.
62+
if is_public:
63+
self._api_key: Optional[str] = None
64+
else:
65+
self._api_key = api_key or os.environ.get("WORKOS_API_KEY")
5866
self.client_id = client_id or os.environ.get("WORKOS_CLIENT_ID")
5967
if not self._api_key and not self.client_id:
6068
raise ValueError(
@@ -80,12 +88,14 @@ def base_url(self) -> str:
8088
"""The base URL for API requests."""
8189
return self._base_url
8290

83-
def build_url(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
91+
def build_url(
92+
self, path: Sequence[str], params: Optional[Dict[str, Any]] = None
93+
) -> str:
8494
"""Build a full URL with query parameters for redirect/authorization endpoints."""
8595
from urllib.parse import urlencode
8696

8797
base = self._base_url.rstrip("/")
88-
url = f"{base}/{path}"
98+
url = f"{base}/{self._encode_path(path)}"
8999
if params:
90100
url = f"{url}?{urlencode(params)}"
91101
return url
@@ -128,6 +138,27 @@ def _resolve_base_url(self, request_options: Optional[RequestOptions]) -> str:
128138
return str(base_url).rstrip("/")
129139
return self._base_url.rstrip("/")
130140

141+
@staticmethod
142+
def _encode_path(path: Sequence[str]) -> str:
143+
"""Percent-encode each path segment and join with ``/``.
144+
145+
Callers pass each path component as a separate element (e.g.
146+
``("organizations", organization_id)``). Each element is URL-encoded
147+
with ``safe=""`` so a caller-supplied id containing ``/``, ``?``,
148+
``#``, ``%``, or ``..`` cannot escape its intended segment — this is
149+
the structural protection against forged cross-resource API requests
150+
under the application's API key.
151+
152+
A bare string would be silently iterable as a sequence of single
153+
characters; we reject it explicitly so a forgotten tuple wrapper at a
154+
call site fails loudly instead of producing a per-character URL.
155+
"""
156+
if isinstance(path, str):
157+
raise TypeError(
158+
"path must be a sequence of segments (e.g. a tuple), not a str"
159+
)
160+
return "/".join(quote(str(seg), safe="") for seg in path)
161+
131162
def _resolve_timeout(self, request_options: Optional[RequestOptions]) -> float:
132163
timeout = self._request_timeout
133164
if request_options:
@@ -332,6 +363,7 @@ def __init__(
332363
request_timeout: Optional[int] = None,
333364
jwt_leeway: float = 0.0,
334365
max_retries: int = MAX_RETRIES,
366+
is_public: bool = False,
335367
) -> None:
336368
"""Initialize the WorkOS client.
337369
@@ -342,6 +374,10 @@ def __init__(
342374
request_timeout: HTTP request timeout in seconds. Falls back to WORKOS_REQUEST_TIMEOUT or 60.
343375
jwt_leeway: JWT clock skew leeway in seconds.
344376
max_retries: Maximum number of retries for failed requests. Defaults to 3.
377+
is_public: When True, mark this client as public (PKCE / browser
378+
/ mobile / CLI). The API key is forced to None and the
379+
``WORKOS_API_KEY`` environment variable is ignored. Use
380+
``create_public_client`` instead of setting this directly.
345381
346382
Raises:
347383
ValueError: If neither api_key nor client_id is provided, directly or via environment variables.
@@ -353,6 +389,7 @@ def __init__(
353389
request_timeout=request_timeout,
354390
jwt_leeway=jwt_leeway,
355391
max_retries=max_retries,
392+
is_public=is_public,
356393
)
357394
self._client = httpx.Client(
358395
timeout=self._request_timeout, follow_redirects=True
@@ -372,7 +409,7 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
372409
def request(
373410
self,
374411
method: str,
375-
path: str,
412+
path: Sequence[str],
376413
*,
377414
model: Type[D],
378415
params: Optional[Dict[str, Any]] = ...,
@@ -385,7 +422,7 @@ def request(
385422
def request(
386423
self,
387424
method: str,
388-
path: str,
425+
path: Sequence[str],
389426
*,
390427
model: None = ...,
391428
params: Optional[Dict[str, Any]] = ...,
@@ -397,7 +434,7 @@ def request(
397434
def request(
398435
self,
399436
method: str,
400-
path: str,
437+
path: Sequence[str],
401438
*,
402439
params: Optional[Dict[str, Any]] = None,
403440
body: Optional[Dict[str, Any]] = None,
@@ -406,7 +443,7 @@ def request(
406443
request_options: Optional[RequestOptions] = None,
407444
) -> Any:
408445
"""Make an HTTP request with retry logic."""
409-
url = f"{self._resolve_base_url(request_options)}/{path}"
446+
url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}"
410447
headers = self._build_headers(method, idempotency_key, request_options)
411448
timeout = self._resolve_timeout(request_options)
412449
max_retries = self._resolve_max_retries(request_options)
@@ -453,7 +490,7 @@ def request(
453490
def request_raw(
454491
self,
455492
method: str,
456-
path: str,
493+
path: Sequence[str],
457494
*,
458495
params: Optional[Dict[str, Any]] = None,
459496
body: Optional[Dict[str, Any]] = None,
@@ -478,7 +515,7 @@ def request_raw(
478515
def request_list(
479516
self,
480517
method: str,
481-
path: str,
518+
path: Sequence[str],
482519
*,
483520
params: Optional[Dict[str, Any]] = None,
484521
body: Optional[Dict[str, Any]] = None,
@@ -500,14 +537,14 @@ def request_list(
500537
)
501538
if not isinstance(result, list):
502539
raise WorkOSError(
503-
f"Expected array response from {method.upper()} /{path}, got {type(result).__name__}"
540+
f"Expected array response from {method.upper()} /{'/'.join(path)}, got {type(result).__name__}"
504541
)
505542
return result
506543

507544
def request_page(
508545
self,
509546
method: str,
510-
path: str,
547+
path: Sequence[str],
511548
*,
512549
model: Type[D],
513550
params: Optional[Dict[str, Any]] = None,
@@ -557,6 +594,7 @@ def __init__(
557594
request_timeout: Optional[int] = None,
558595
jwt_leeway: float = 0.0,
559596
max_retries: int = MAX_RETRIES,
597+
is_public: bool = False,
560598
) -> None:
561599
"""Initialize the async WorkOS client.
562600
@@ -578,6 +616,7 @@ def __init__(
578616
request_timeout=request_timeout,
579617
jwt_leeway=jwt_leeway,
580618
max_retries=max_retries,
619+
is_public=is_public,
581620
)
582621
self._client = httpx.AsyncClient(
583622
timeout=self._request_timeout, follow_redirects=True
@@ -597,7 +636,7 @@ async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
597636
async def request(
598637
self,
599638
method: str,
600-
path: str,
639+
path: Sequence[str],
601640
*,
602641
model: Type[D],
603642
params: Optional[Dict[str, Any]] = ...,
@@ -610,7 +649,7 @@ async def request(
610649
async def request(
611650
self,
612651
method: str,
613-
path: str,
652+
path: Sequence[str],
614653
*,
615654
model: None = ...,
616655
params: Optional[Dict[str, Any]] = ...,
@@ -622,7 +661,7 @@ async def request(
622661
async def request(
623662
self,
624663
method: str,
625-
path: str,
664+
path: Sequence[str],
626665
*,
627666
params: Optional[Dict[str, Any]] = None,
628667
body: Optional[Dict[str, Any]] = None,
@@ -631,7 +670,7 @@ async def request(
631670
request_options: Optional[RequestOptions] = None,
632671
) -> Any:
633672
"""Make an async HTTP request with retry logic."""
634-
url = f"{self._resolve_base_url(request_options)}/{path}"
673+
url = f"{self._resolve_base_url(request_options)}/{self._encode_path(path)}"
635674
headers = self._build_headers(method, idempotency_key, request_options)
636675
timeout = self._resolve_timeout(request_options)
637676
max_retries = self._resolve_max_retries(request_options)
@@ -678,7 +717,7 @@ async def request(
678717
async def request_raw(
679718
self,
680719
method: str,
681-
path: str,
720+
path: Sequence[str],
682721
*,
683722
params: Optional[Dict[str, Any]] = None,
684723
body: Optional[Dict[str, Any]] = None,
@@ -703,7 +742,7 @@ async def request_raw(
703742
async def request_list(
704743
self,
705744
method: str,
706-
path: str,
745+
path: Sequence[str],
707746
*,
708747
params: Optional[Dict[str, Any]] = None,
709748
body: Optional[Dict[str, Any]] = None,
@@ -725,14 +764,14 @@ async def request_list(
725764
)
726765
if not isinstance(result, list):
727766
raise WorkOSError(
728-
f"Expected array response from {method.upper()} /{path}, got {type(result).__name__}"
767+
f"Expected array response from {method.upper()} /{'/'.join(path)}, got {type(result).__name__}"
729768
)
730769
return result
731770

732771
async def request_page(
733772
self,
734773
method: str,
735-
path: str,
774+
path: Sequence[str],
736775
*,
737776
model: Type[D],
738777
params: Optional[Dict[str, Any]] = None,

src/workos/actions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ def _verify_signature(
4444
timestamp_in_seconds = int(issued_timestamp) / 1000
4545
seconds_since_issued = current_time - timestamp_in_seconds
4646

47-
if seconds_since_issued > tolerance:
47+
if abs(seconds_since_issued) > tolerance:
4848
raise ValueError("Timestamp outside the tolerance zone")
4949

5050
body_str = payload.decode("utf-8") if isinstance(payload, bytes) else payload

src/workos/admin_portal/_resource.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def generate_link(
7777
}
7878
return self._client.request(
7979
method="post",
80-
path="portal/generate_link",
80+
path=("portal", "generate_link"),
8181
body=body,
8282
model=PortalLinkResponse,
8383
request_options=request_options,
@@ -149,7 +149,7 @@ async def generate_link(
149149
}
150150
return await self._client.request(
151151
method="post",
152-
path="portal/generate_link",
152+
path=("portal", "generate_link"),
153153
body=body,
154154
model=PortalLinkResponse,
155155
request_options=request_options,

src/workos/api_keys/_resource.py

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
from __future__ import annotations
44

55
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
6-
from urllib.parse import quote
76

87
if TYPE_CHECKING:
98
from .._client import AsyncWorkOSClient, WorkOSClient
@@ -67,7 +66,7 @@ def list_organization_api_keys(
6766
}
6867
return self._client.request_page(
6968
method="get",
70-
path=f"organizations/{quote(str(organization_id), safe='')}/api_keys",
69+
path=("organizations", str(organization_id), "api_keys"),
7170
model=OrganizationApiKey,
7271
params=params,
7372
request_options=request_options,
@@ -111,7 +110,7 @@ def create_organization_api_key(
111110
}
112111
return self._client.request(
113112
method="post",
114-
path=f"organizations/{quote(str(organization_id), safe='')}/api_keys",
113+
path=("organizations", str(organization_id), "api_keys"),
115114
body=body,
116115
model=OrganizationApiKeyWithValue,
117116
request_options=request_options,
@@ -145,7 +144,7 @@ def create_validation(
145144
}
146145
return self._client.request(
147146
method="post",
148-
path="api_keys/validations",
147+
path=("api_keys", "validations"),
149148
body=body,
150149
model=ApiKeyValidationResponse,
151150
request_options=request_options,
@@ -173,7 +172,7 @@ def delete_api_key(
173172
"""
174173
self._client.request(
175174
method="delete",
176-
path=f"api_keys/{quote(str(id), safe='')}",
175+
path=("api_keys", str(id)),
177176
request_options=request_options,
178177
)
179178

@@ -227,7 +226,7 @@ async def list_organization_api_keys(
227226
}
228227
return await self._client.request_page(
229228
method="get",
230-
path=f"organizations/{quote(str(organization_id), safe='')}/api_keys",
229+
path=("organizations", str(organization_id), "api_keys"),
231230
model=OrganizationApiKey,
232231
params=params,
233232
request_options=request_options,
@@ -271,7 +270,7 @@ async def create_organization_api_key(
271270
}
272271
return await self._client.request(
273272
method="post",
274-
path=f"organizations/{quote(str(organization_id), safe='')}/api_keys",
273+
path=("organizations", str(organization_id), "api_keys"),
275274
body=body,
276275
model=OrganizationApiKeyWithValue,
277276
request_options=request_options,
@@ -305,7 +304,7 @@ async def create_validation(
305304
}
306305
return await self._client.request(
307306
method="post",
308-
path="api_keys/validations",
307+
path=("api_keys", "validations"),
309308
body=body,
310309
model=ApiKeyValidationResponse,
311310
request_options=request_options,
@@ -333,6 +332,6 @@ async def delete_api_key(
333332
"""
334333
await self._client.request(
335334
method="delete",
336-
path=f"api_keys/{quote(str(id), safe='')}",
335+
path=("api_keys", str(id)),
337336
request_options=request_options,
338337
)

0 commit comments

Comments
 (0)