99import random
1010from datetime import datetime , timezone
1111from 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
1415import 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 ,
0 commit comments