volcano-sdk-python 0.8.0__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ """Internal session continuity checks shared by refresh and revocation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+ from collections.abc import Mapping
8
+ from typing import TYPE_CHECKING, cast
9
+ from uuid import UUID
10
+
11
+ from .errors import AuthenticationError
12
+
13
+ if TYPE_CHECKING:
14
+ from .models import Session
15
+
16
+ _JWT_PARTS = 3
17
+ _REFRESH_USER_MISMATCH = "Refreshed session belongs to a different user"
18
+ _REFRESH_SESSION_MISMATCH = "Refreshed credentials belong to a different server session"
19
+ _MISSING_SESSION_ID = "Cannot refresh supplied credentials without a session identifier"
20
+
21
+
22
+ def session_id_from_access_token(access_token: str) -> str | None:
23
+ """Read an untrusted continuity constraint; this never authenticates a user."""
24
+ parts = access_token.split(".")
25
+ if len(parts) != _JWT_PARTS:
26
+ return None
27
+ padding = "=" * (-len(parts[1]) % 4)
28
+ try:
29
+ payload: object = json.loads(
30
+ base64.urlsafe_b64decode(parts[1] + padding).decode()
31
+ )
32
+ except (ValueError, UnicodeDecodeError, RecursionError):
33
+ return None
34
+ if not isinstance(payload, Mapping):
35
+ return None
36
+ values = cast("Mapping[object, object]", payload)
37
+ session_id = values.get("session_id")
38
+ if not isinstance(session_id, str) or not session_id.strip():
39
+ return None
40
+ try:
41
+ return str(UUID(session_id.strip()))
42
+ except ValueError:
43
+ return None
44
+
45
+
46
+ def validate_refresh_source(current: Session, *, verified: bool = False) -> None:
47
+ """Require a session constraint for supplied credentials."""
48
+ if not verified and session_id_from_access_token(current.access_token) is None:
49
+ raise AuthenticationError(_MISSING_SESSION_ID)
50
+
51
+
52
+ def validate_refresh_identity(current: Session | None, refreshed: Session) -> None:
53
+ """Reject a refresh outside the captured server session or validated user."""
54
+ if current is None:
55
+ return
56
+ session_id = session_id_from_access_token(current.access_token)
57
+ if session_id is not None and session_id != session_id_from_access_token(
58
+ refreshed.access_token
59
+ ):
60
+ raise AuthenticationError(_REFRESH_SESSION_MISMATCH)
61
+ if current.user_id is None:
62
+ return
63
+ if current.user_id == refreshed.user_id:
64
+ return
65
+ try:
66
+ matches = UUID(current.user_id) == UUID(str(refreshed.user_id))
67
+ except ValueError:
68
+ matches = False
69
+ if not matches:
70
+ raise AuthenticationError(_REFRESH_USER_MISMATCH)
@@ -0,0 +1,158 @@
1
+ """Bound refresh and revocation work to one explicitly established session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from concurrent.futures import Future
6
+ from threading import Lock
7
+ from typing import TYPE_CHECKING, TypeVar
8
+
9
+ from .errors import SessionChangedError, VolcanoError
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable
13
+
14
+ from .models import Session
15
+
16
+ _T = TypeVar("_T")
17
+
18
+
19
+ def _copy_failure(error: BaseException, *, include_cause: bool = True) -> BaseException:
20
+ """Copy public failure details without retaining request frames or contexts."""
21
+ copied = type(error).__new__(type(error), *error.args)
22
+ if isinstance(error, VolcanoError) and isinstance(copied, VolcanoError):
23
+ copied.status = error.status
24
+ copied.code = error.code
25
+ copied.retry_after = error.retry_after
26
+ if include_cause and isinstance(error.__cause__, VolcanoError):
27
+ copied.__cause__ = _copy_failure(error.__cause__, include_cause=False)
28
+ return copied
29
+
30
+
31
+ class SessionOperations:
32
+ """Retain only this session's latest refresh and shared sign-out outcome."""
33
+
34
+ def __init__(self, verified: Session | None = None) -> None:
35
+ self._lock = Lock()
36
+ self.refreshing: Future[Session] | None = None
37
+ self.signing_out: Future[BaseException | None] | None = None
38
+ self._locally_cleared = False
39
+ self._verified_pair = (
40
+ (verified.access_token, verified.refresh_token)
41
+ if verified is not None
42
+ else None
43
+ )
44
+
45
+ def verify_pair(self, session: Session | None) -> None:
46
+ with self._lock:
47
+ if self._locally_cleared:
48
+ return
49
+ self._verified_pair = (
50
+ (session.access_token, session.refresh_token)
51
+ if session is not None
52
+ else None
53
+ )
54
+
55
+ def clear_local_credentials(self) -> None:
56
+ """Keep pending revocation joinable; discard credentials after other clears."""
57
+ with self._lock:
58
+ if self.signing_out is not None:
59
+ return
60
+ self._locally_cleared = True
61
+ self._verified_pair = None
62
+ refreshing = self.refreshing
63
+ if refreshing is not None:
64
+ refreshing.add_done_callback(self._forget_refresh)
65
+
66
+ def _forget_refresh(self, refreshing: Future[Session]) -> None:
67
+ with self._lock:
68
+ if self.refreshing is refreshing:
69
+ self.refreshing = None
70
+
71
+ def has_verified_pair(self, session: Session) -> bool:
72
+ with self._lock:
73
+ return session.refresh_token is not None and self._verified_pair == (
74
+ session.access_token,
75
+ session.refresh_token,
76
+ )
77
+
78
+ def refresh(self, operation: Callable[[], Session]) -> Session:
79
+ with self._lock:
80
+ if self.signing_out is not None or self._locally_cleared:
81
+ raise SessionChangedError
82
+ future = self.refreshing
83
+ if future is None or future.done():
84
+ future = self.refreshing = Future()
85
+ owner = True
86
+ else:
87
+ owner = False
88
+ if owner:
89
+ self._complete(future, operation)
90
+ return future.result()
91
+
92
+ def sign_out(
93
+ self, operation: Callable[[Future[Session] | None, bool], None]
94
+ ) -> None:
95
+ with self._lock:
96
+ future = self.signing_out
97
+ if future is None:
98
+ future = self.signing_out = Future()
99
+ owner = True
100
+ else:
101
+ owner = False
102
+ preceding = self.refreshing
103
+ pending = preceding is not None and not preceding.done()
104
+ if owner:
105
+ self._complete_revocation(future, operation, preceding, pending=pending)
106
+ self._sign_out_result(future)
107
+
108
+ def wait_for_sign_out(self) -> None:
109
+ with self._lock:
110
+ future = self.signing_out
111
+ pending = future is not None and not future.done()
112
+ if pending and future is not None:
113
+ self._sign_out_result(future)
114
+
115
+ @staticmethod
116
+ def _sign_out_result(future: Future[BaseException | None]) -> None:
117
+ failure = future.result()
118
+ if failure is not None:
119
+ # Raising the retained template would attach credential-bearing frames.
120
+ raise _copy_failure(failure)
121
+
122
+ def _complete_revocation(
123
+ self,
124
+ future: Future[BaseException | None],
125
+ operation: Callable[[Future[Session] | None, bool], None],
126
+ preceding: Future[Session] | None,
127
+ *,
128
+ pending: bool,
129
+ ) -> None:
130
+ try:
131
+ self._revoke(operation, preceding, pending=pending)
132
+ except BaseException as error:
133
+ future.set_result(_copy_failure(error))
134
+ raise
135
+ future.set_result(None)
136
+
137
+ def _revoke(
138
+ self,
139
+ operation: Callable[[Future[Session] | None, bool], None],
140
+ preceding: Future[Session] | None,
141
+ *,
142
+ pending: bool,
143
+ ) -> None:
144
+ try:
145
+ operation(preceding, pending)
146
+ finally:
147
+ with self._lock:
148
+ self._verified_pair = None
149
+ self.refreshing = None
150
+
151
+ @staticmethod
152
+ def _complete(future: Future[_T], operation: Callable[[], _T]) -> None:
153
+ try:
154
+ future.set_result(operation())
155
+ except BaseException as error:
156
+ # Every waiter must finish, including when the owner is interrupted.
157
+ future.set_exception(error)
158
+ raise
volcano_sdk/auth.py CHANGED
@@ -2,11 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import base64
6
- import binascii
7
- import json
8
5
  import secrets
9
- import threading
10
6
  from collections.abc import Mapping
11
7
  from contextlib import suppress
12
8
  from copy import deepcopy
@@ -43,6 +39,11 @@ from ._generated.models.refresh_o_auth_provider_token_response_200 import (
43
39
  RefreshOAuthProviderTokenResponse200,
44
40
  )
45
41
  from ._generated.types import Unset
42
+ from ._session import (
43
+ session_id_from_access_token,
44
+ validate_refresh_identity,
45
+ validate_refresh_source,
46
+ )
46
47
  from ._transport import (
47
48
  AuthCallOAuthAPITransport,
48
49
  AuthCancelEmailChangeTransport,
@@ -75,6 +76,7 @@ from ._transport import (
75
76
  )
76
77
  from .errors import (
77
78
  AuthenticationError,
79
+ RateLimitedError,
78
80
  SessionChangedError,
79
81
  TransportError,
80
82
  VolcanoError,
@@ -115,8 +117,8 @@ _INVALID_OAUTH_PARAMETER = "OAuth parameters must be non-empty strings"
115
117
  _INVALID_OAUTH_STATE = "OAuth state must not exceed 255 characters"
116
118
  _OAUTH_STATE_MISMATCH = "OAuth state mismatch"
117
119
  _MAX_OAUTH_STATE_LENGTH = 255
118
- _JWT_PARTS = 3
119
120
  _NO_ACTIVE_SESSION = "No active session"
121
+ _REFRESH_UNAVAILABLE = "No refresh token"
120
122
  _T = TypeVar("_T")
121
123
  _OAUTH_PROVIDERS: frozenset[str] = frozenset({"apple", "github", "google", "microsoft"})
122
124
  _OAUTH_API_METHODS: frozenset[str] = frozenset({"GET", "POST"})
@@ -124,11 +126,13 @@ _HOSTED_AUTH_ACTIONS: frozenset[str] = frozenset({"login", "signup", "forgot-pas
124
126
 
125
127
  if TYPE_CHECKING:
126
128
  from collections.abc import Callable
129
+ from concurrent.futures import Future
127
130
 
128
131
  from ._generated.models import (
129
132
  AuthListOAuthProvidersResponse200ProvidersItem,
130
133
  )
131
134
  from ._generated.models.auth_session import AuthSession as GeneratedAuthSession
135
+ from ._session_operations import SessionOperations
132
136
  from ._transport import TransportResponse
133
137
 
134
138
 
@@ -169,26 +173,6 @@ def _validate_oauth_callback_state(state: str, expected_state: str) -> None:
169
173
  raise ValueError(_OAUTH_STATE_MISMATCH)
170
174
 
171
175
 
172
- def _session_id_from_access_token(access_token: str) -> str | None:
173
- parts = access_token.split(".")
174
- if len(parts) != _JWT_PARTS:
175
- return None
176
- padding = "=" * (-len(parts[1]) % 4)
177
- try:
178
- payload: object = json.loads(
179
- base64.urlsafe_b64decode(parts[1] + padding).decode()
180
- )
181
- except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError):
182
- return None
183
- if not isinstance(payload, Mapping):
184
- return None
185
- values = cast("Mapping[object, object]", payload)
186
- session_id = values.get("session_id")
187
- if not isinstance(session_id, str) or not session_id.strip():
188
- return None
189
- return session_id.strip()
190
-
191
-
192
176
  def _has_complete_values(session: Session) -> bool:
193
177
  return all(
194
178
  _is_non_empty_string(value)
@@ -468,7 +452,9 @@ class AuthContext(Protocol):
468
452
 
469
453
  def _capture_session(self) -> tuple[int, Session | None]: ...
470
454
 
471
- def _capture_session_binding(self) -> tuple[int, int, Session | None]: ...
455
+ def _capture_session_binding(
456
+ self,
457
+ ) -> tuple[int, SessionOperations, Session | None]: ...
472
458
 
473
459
  def _update_session_user_if_current(
474
460
  self, user: Mapping[str, JSONValue], generation: int
@@ -487,6 +473,7 @@ class AuthContext(Protocol):
487
473
  self,
488
474
  generation: int,
489
475
  *,
476
+ lineage: SessionOperations | None = None,
490
477
  event: AuthChangeEvent = "SIGNED_OUT",
491
478
  notifications: list[Callable[[], None]] | None = None,
492
479
  ) -> bool: ...
@@ -503,8 +490,7 @@ class Auth:
503
490
  def __init__(self, client: AuthContext) -> None:
504
491
  """Create an authentication facade backed by a client."""
505
492
  self._client = client
506
- self._refresh_lock = threading.RLock()
507
- self._rejected_refresh: tuple[int, int] | None = None
493
+ self._rejected_refresh: tuple[int, SessionOperations] | None = None
508
494
 
509
495
  def get_session(self) -> Session | None:
510
496
  """Return the immutable locally held session without validating it."""
@@ -604,33 +590,37 @@ class Auth:
604
590
 
605
591
  def request_email_change(self, *, new_email: str) -> EmailChangeResult:
606
592
  """Request a confirmation email without changing the current session."""
607
- generation, current = self._client._capture_session()
608
- if current is None:
593
+ binding = self._client._capture_session_binding()
594
+ if binding[2] is None:
609
595
  raise AuthenticationError(_NO_ACTIVE_SESSION)
610
596
  transport = cast("AuthRequestEmailChangeTransport", self._client._transport)
611
- response = invoke(
612
- transport.auth_request_email_change,
613
- authorization=current.access_token,
614
- new_email=new_email,
597
+ response = self._session_request(
598
+ lambda access_token: invoke(
599
+ transport.auth_request_email_change,
600
+ authorization=access_token,
601
+ new_email=new_email,
602
+ ),
603
+ binding=binding,
615
604
  )
616
605
  result = _email_change_result_from_payload(response_payload(response, 200))
617
- if self._client._capture_session()[0] != generation:
618
- raise SessionChangedError
606
+ self._owned_refresh_session(binding)
619
607
  return result
620
608
 
621
609
  def cancel_email_change(self) -> None:
622
610
  """Cancel a pending email change without changing the current session."""
623
- generation, current = self._client._capture_session()
624
- if current is None:
611
+ binding = self._client._capture_session_binding()
612
+ if binding[2] is None:
625
613
  raise AuthenticationError(_NO_ACTIVE_SESSION)
626
614
  transport = cast("AuthCancelEmailChangeTransport", self._client._transport)
627
- response = invoke(
628
- transport.auth_cancel_email_change,
629
- authorization=current.access_token,
615
+ response = self._session_request(
616
+ lambda access_token: invoke(
617
+ transport.auth_cancel_email_change,
618
+ authorization=access_token,
619
+ ),
620
+ binding=binding,
630
621
  )
631
622
  response_payload(response, 200)
632
- if self._client._capture_session()[0] != generation:
633
- raise SessionChangedError
623
+ self._owned_refresh_session(binding)
634
624
 
635
625
  def confirm_email_change(self, *, token: str) -> User:
636
626
  """Confirm a pending email change and return the updated user."""
@@ -650,48 +640,54 @@ class Auth:
650
640
 
651
641
  def delete_all_other_sessions(self) -> None:
652
642
  """Delete every other session while preserving the current session."""
653
- generation, current = self._client._capture_session()
654
- if current is None:
643
+ binding = self._client._capture_session_binding()
644
+ if binding[2] is None:
655
645
  raise AuthenticationError(_NO_ACTIVE_SESSION)
656
646
  transport = cast("AuthDeleteAllMySessionsTransport", self._client._transport)
657
- response = invoke(
658
- transport.auth_delete_all_my_sessions,
659
- authorization=current.access_token,
647
+ response = self._session_request(
648
+ lambda access_token: invoke(
649
+ transport.auth_delete_all_my_sessions,
650
+ authorization=access_token,
651
+ ),
652
+ binding=binding,
660
653
  )
661
654
  response_payload(response, 204)
662
- if self._client._capture_session()[0] != generation:
663
- raise SessionChangedError
655
+ self._owned_refresh_session(binding)
664
656
 
665
657
  def list_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage:
666
658
  """List sessions in the stable offset-paginated activity order."""
667
- generation, current = self._client._capture_session()
668
- if current is None:
659
+ binding = self._client._capture_session_binding()
660
+ if binding[2] is None:
669
661
  raise AuthenticationError(_NO_ACTIVE_SESSION)
670
662
  transport = cast("AuthGetMySessionsTransport", self._client._transport)
671
- response = invoke(
672
- transport.auth_get_my_sessions,
673
- authorization=current.access_token,
674
- page=page,
675
- limit=limit,
663
+ response = self._session_request(
664
+ lambda access_token: invoke(
665
+ transport.auth_get_my_sessions,
666
+ authorization=access_token,
667
+ page=page,
668
+ limit=limit,
669
+ ),
670
+ binding=binding,
676
671
  )
677
672
  result = _session_page_from_payload(response_payload(response, 200))
678
- if self._client._capture_session()[0] != generation:
679
- raise SessionChangedError
673
+ self._owned_refresh_session(binding)
680
674
  return result
681
675
 
682
676
  def list_linked_oauth_providers(self) -> tuple[LinkedOAuthProvider, ...]:
683
677
  """List OAuth providers linked to the current account."""
684
- generation, current = self._client._capture_session()
685
- if current is None:
678
+ binding = self._client._capture_session_binding()
679
+ if binding[2] is None:
686
680
  raise AuthenticationError(_NO_ACTIVE_SESSION)
687
681
  transport = cast("AuthListOAuthProvidersTransport", self._client._transport)
688
- response = invoke(
689
- transport.auth_list_oauth_providers,
690
- authorization=current.access_token,
682
+ response = self._session_request(
683
+ lambda access_token: invoke(
684
+ transport.auth_list_oauth_providers,
685
+ authorization=access_token,
686
+ ),
687
+ binding=binding,
691
688
  )
692
689
  result = _linked_oauth_providers_from_payload(response_payload(response, 200))
693
- if self._client._capture_session()[0] != generation:
694
- raise SessionChangedError
690
+ self._owned_refresh_session(binding)
695
691
  return result
696
692
 
697
693
  def get_hosted_auth_url(
@@ -777,38 +773,42 @@ class Auth:
777
773
  def link_oauth_provider(self, *, provider: OAuthProviderName) -> str:
778
774
  """Return the authorization URL for linking an OAuth provider."""
779
775
  provider_name = _oauth_provider_name(provider)
780
- generation, current = self._client._capture_session()
781
- if current is None:
776
+ binding = self._client._capture_session_binding()
777
+ if binding[2] is None:
782
778
  raise AuthenticationError(_NO_ACTIVE_SESSION)
783
779
  transport = cast("AuthLinkOAuthProviderTransport", self._client._transport)
784
- response = invoke(
785
- transport.auth_link_oauth_provider,
786
- authorization=current.access_token,
787
- provider=provider_name,
780
+ response = self._session_request(
781
+ lambda access_token: invoke(
782
+ transport.auth_link_oauth_provider,
783
+ authorization=access_token,
784
+ provider=provider_name,
785
+ ),
786
+ binding=binding,
788
787
  )
789
788
  result = _oauth_link_from_payload(response_payload(response, 200))
790
- if self._client._capture_session()[0] != generation:
791
- raise SessionChangedError
789
+ self._owned_refresh_session(binding)
792
790
  return result
793
791
 
794
792
  def unlink_oauth_provider(self, *, provider: OAuthProviderName) -> None:
795
793
  """Unlink an OAuth provider from the current account."""
796
794
  provider_name = _oauth_provider_name(provider)
797
- generation, current = self._client._capture_session()
798
- if current is None:
795
+ binding = self._client._capture_session_binding()
796
+ if binding[2] is None:
799
797
  raise AuthenticationError(_NO_ACTIVE_SESSION)
800
798
  transport = cast(
801
799
  "AuthUnlinkOAuthProviderTransport",
802
800
  self._client._transport,
803
801
  )
804
- response = invoke(
805
- transport.auth_unlink_oauth_provider,
806
- authorization=current.access_token,
807
- provider=provider_name,
802
+ response = self._session_request(
803
+ lambda access_token: invoke(
804
+ transport.auth_unlink_oauth_provider,
805
+ authorization=access_token,
806
+ provider=provider_name,
807
+ ),
808
+ binding=binding,
808
809
  )
809
810
  response_payload(response, 204)
810
- if self._client._capture_session()[0] != generation:
811
- raise SessionChangedError
811
+ self._owned_refresh_session(binding)
812
812
 
813
813
  def get_oauth_provider_token(
814
814
  self,
@@ -817,23 +817,25 @@ class Auth:
817
817
  ) -> OAuthProviderTokenStatus:
818
818
  """Return validity metadata for a server-held OAuth provider token."""
819
819
  provider_name = _oauth_provider_name(provider)
820
- generation, current = self._client._capture_session()
821
- if current is None:
820
+ binding = self._client._capture_session_binding()
821
+ if binding[2] is None:
822
822
  raise AuthenticationError(_NO_ACTIVE_SESSION)
823
823
  transport = cast(
824
824
  "AuthGetOAuthProviderTokenTransport",
825
825
  self._client._transport,
826
826
  )
827
- response = invoke(
828
- transport.auth_get_oauth_provider_token,
829
- authorization=current.access_token,
830
- provider=provider_name,
827
+ response = self._session_request(
828
+ lambda access_token: invoke(
829
+ transport.auth_get_oauth_provider_token,
830
+ authorization=access_token,
831
+ provider=provider_name,
832
+ ),
833
+ binding=binding,
831
834
  )
832
835
  result = _oauth_provider_token_status_from_payload(
833
836
  response_payload(response, 200)
834
837
  )
835
- if self._client._capture_session()[0] != generation:
836
- raise SessionChangedError
838
+ self._owned_refresh_session(binding)
837
839
  return result
838
840
 
839
841
  def refresh_oauth_provider_token(
@@ -843,23 +845,25 @@ class Auth:
843
845
  ) -> OAuthProviderTokenStatus:
844
846
  """Refresh a server-held OAuth provider token and return its status."""
845
847
  provider_name = _oauth_provider_name(provider)
846
- generation, current = self._client._capture_session()
847
- if current is None:
848
+ binding = self._client._capture_session_binding()
849
+ if binding[2] is None:
848
850
  raise AuthenticationError(_NO_ACTIVE_SESSION)
849
851
  transport = cast(
850
852
  "AuthRefreshOAuthProviderTokenTransport",
851
853
  self._client._transport,
852
854
  )
853
- response = invoke(
854
- transport.auth_refresh_oauth_provider_token,
855
- authorization=current.access_token,
856
- provider=provider_name,
855
+ response = self._session_request(
856
+ lambda access_token: invoke(
857
+ transport.auth_refresh_oauth_provider_token,
858
+ authorization=access_token,
859
+ provider=provider_name,
860
+ ),
861
+ binding=binding,
857
862
  )
858
863
  result = _oauth_provider_token_status_from_payload(
859
864
  response_payload(response, 200)
860
865
  )
861
- if self._client._capture_session()[0] != generation:
862
- raise SessionChangedError
866
+ self._owned_refresh_session(binding)
863
867
  return result
864
868
 
865
869
  def call_oauth_api(
@@ -873,53 +877,61 @@ class Auth:
873
877
  """Call a provider API through Volcano's fixed-host server proxy."""
874
878
  provider_name = _oauth_provider_name(provider)
875
879
  request_method = _oauth_api_method(method)
876
- generation, current = self._client._capture_session()
877
- if current is None:
880
+ binding = self._client._capture_session_binding()
881
+ if binding[2] is None:
878
882
  raise AuthenticationError(_NO_ACTIVE_SESSION)
879
883
  transport = cast("AuthCallOAuthAPITransport", self._client._transport)
880
- response = invoke(
881
- transport.auth_call_oauth_api,
882
- authorization=current.access_token,
883
- provider=provider_name,
884
- endpoint=endpoint,
885
- method=request_method,
886
- body=body,
884
+ request_body = deepcopy(dict(body)) if body is not None else None
885
+ response = self._session_request(
886
+ lambda access_token: invoke(
887
+ transport.auth_call_oauth_api,
888
+ authorization=access_token,
889
+ provider=provider_name,
890
+ endpoint=endpoint,
891
+ method=request_method,
892
+ body=request_body,
893
+ ),
894
+ binding=binding,
887
895
  )
888
896
  result = _oauth_api_data_from_payload(response_payload(response, 200))
889
- if self._client._capture_session()[0] != generation:
890
- raise SessionChangedError
897
+ self._owned_refresh_session(binding)
891
898
  return result
892
899
 
893
900
  def delete_session(self, *, session_id: str) -> None:
894
901
  """Delete one session and clear local state when it is current."""
895
- generation, current = self._client._capture_session()
902
+ binding = self._client._capture_session_binding()
903
+ generation, lineage, current = binding
896
904
  if current is None:
897
905
  raise AuthenticationError(_NO_ACTIVE_SESSION)
898
- current_session_id = _session_id_from_access_token(current.access_token)
906
+ current_session_id = session_id_from_access_token(current.access_token)
899
907
  deletes_current = (
900
908
  current_session_id is not None
901
909
  and current_session_id.casefold() == session_id.casefold()
902
910
  )
903
911
  transport = cast("AuthDeleteMySessionTransport", self._client._transport)
904
912
  try:
905
- response = invoke(
906
- transport.auth_delete_my_session,
907
- authorization=current.access_token,
908
- session_id=session_id,
913
+ response = self._session_request(
914
+ lambda access_token: invoke(
915
+ transport.auth_delete_my_session,
916
+ authorization=access_token,
917
+ session_id=session_id,
918
+ ),
919
+ binding=binding,
909
920
  )
910
921
  response_payload(response, 204)
911
922
  except TransportError as error:
912
923
  if deletes_current and not self._client._clear_session_if_current(
913
- generation
924
+ generation, lineage=lineage
914
925
  ):
915
926
  raise SessionChangedError from error
916
927
  raise
917
928
  if deletes_current:
918
- current_unchanged = self._client._clear_session_if_current(generation)
929
+ if not self._client._clear_session_if_current(generation, lineage=lineage):
930
+ raise SessionChangedError
919
931
  else:
920
- current_unchanged = self._client._capture_session()[0] == generation
921
- if not current_unchanged:
922
- raise SessionChangedError
932
+ _, active_lineage, active_session = self._client._capture_session_binding()
933
+ if active_lineage is not lineage or active_session is None:
934
+ raise SessionChangedError
923
935
 
924
936
  def confirm_email(self, *, token: str) -> None:
925
937
  """Confirm an email with its token without changing local state."""
@@ -953,7 +965,7 @@ class Auth:
953
965
  response_payload(response, 200)
954
966
 
955
967
  def _update_current_user(
956
- self, payload: object, binding: tuple[int, int, Session | None]
968
+ self, payload: object, binding: tuple[int, SessionOperations, Session | None]
957
969
  ) -> User:
958
970
  generation = self._owned_refresh_session(binding)[0]
959
971
  user, snapshot = _user_from_payload(payload)
@@ -1030,7 +1042,7 @@ class Auth:
1030
1042
  self,
1031
1043
  operation: Callable[[str], TransportResponse],
1032
1044
  *,
1033
- binding: tuple[int, int, Session | None] | None = None,
1045
+ binding: tuple[int, SessionOperations, Session | None] | None = None,
1034
1046
  ) -> TransportResponse:
1035
1047
  if binding is None:
1036
1048
  binding = self._client._capture_session_binding()
@@ -1057,24 +1069,31 @@ class Auth:
1057
1069
  self._owned_refresh_session(binding)
1058
1070
  return response
1059
1071
 
1060
- def _validate_read_failure(self, binding: tuple[int, int, Session | None]) -> None:
1072
+ def _validate_read_failure(
1073
+ self, binding: tuple[int, SessionOperations, Session | None]
1074
+ ) -> None:
1061
1075
  with suppress(AuthenticationError):
1062
1076
  self._owned_refresh_session(binding)
1063
1077
 
1064
1078
  def _refresh_session_for_binding(
1065
- self, binding: tuple[int, int, Session | None]
1079
+ self, binding: tuple[int, SessionOperations, Session | None]
1066
1080
  ) -> Session:
1067
- generation, _, current = binding
1081
+ generation, owner, current = binding
1068
1082
  if current is None:
1069
1083
  raise AuthenticationError(_NO_ACTIVE_SESSION)
1070
1084
  notifications: list[Callable[[], None]] = []
1071
1085
  try:
1072
- with self._refresh_lock:
1073
- active_generation, _, _ = self._owned_refresh_session(binding)
1074
- if active_generation == generation:
1075
- self._perform_refresh(binding, current, notifications)
1086
+ active_generation, _, _ = self._owned_refresh_session(binding)
1087
+ if active_generation == generation:
1088
+ owner.refresh(
1089
+ lambda: self._perform_refresh(binding, current, notifications)
1090
+ )
1091
+ if owner.signing_out is not None:
1092
+ raise SessionChangedError
1093
+ except VolcanoError:
1094
+ self._validate_read_failure(binding)
1095
+ raise
1076
1096
  finally:
1077
- # Only the dispatcher owner drains, after refresh coordination is released.
1078
1097
  for dispatch in notifications:
1079
1098
  dispatch()
1080
1099
  active = self._owned_refresh_session(binding)[2]
@@ -1083,14 +1102,14 @@ class Auth:
1083
1102
  return active
1084
1103
 
1085
1104
  def _owned_refresh_session(
1086
- self, binding: tuple[int, int, Session | None]
1087
- ) -> tuple[int, int, Session | None]:
1105
+ self, binding: tuple[int, SessionOperations, Session | None]
1106
+ ) -> tuple[int, SessionOperations, Session | None]:
1088
1107
  generation, lineage, _ = binding
1089
1108
  active = self._client._capture_session_binding()
1090
- if self._rejected_refresh == (generation, lineage) and active == (
1091
- generation + 1,
1092
- lineage + 1,
1093
- None,
1109
+ if (
1110
+ self._rejected_refresh == (generation, lineage)
1111
+ and active[0] == generation + 1
1112
+ and active[2] is None
1094
1113
  ):
1095
1114
  raise AuthenticationError(_NO_ACTIVE_SESSION)
1096
1115
  if active[1] != lineage or active[2] is None:
@@ -1099,35 +1118,49 @@ class Auth:
1099
1118
 
1100
1119
  def _perform_refresh(
1101
1120
  self,
1102
- binding: tuple[int, int, Session | None],
1121
+ binding: tuple[int, SessionOperations, Session | None],
1103
1122
  current: Session,
1104
1123
  notifications: list[Callable[[], None]],
1105
- ) -> None:
1106
- generation, lineage, _ = binding
1124
+ ) -> Session:
1125
+ generation, owner, _ = binding
1126
+ active_generation, _, active = self._owned_refresh_session(binding)
1127
+ if active_generation != generation and active is not None:
1128
+ return active
1129
+ if current.refresh_token is None:
1130
+ raise AuthenticationError(_REFRESH_UNAVAILABLE)
1131
+ verified = owner.has_verified_pair(current)
1132
+ validate_refresh_source(current, verified=verified)
1133
+ owner.verify_pair(None)
1107
1134
  try:
1108
- refreshed = self._request_refreshed_session(current)
1135
+ refreshed = self._request_refreshed_session(current.refresh_token)
1136
+ except RateLimitedError:
1137
+ if verified:
1138
+ owner.verify_pair(current)
1139
+ raise
1109
1140
  except AuthenticationError:
1110
- if not self._client._clear_session_if_current(
1141
+ if owner.signing_out is None and self._client._clear_session_if_current(
1111
1142
  generation, notifications=notifications
1112
1143
  ):
1113
- raise SessionChangedError from None
1114
- self._rejected_refresh = (generation, lineage)
1115
- raise
1116
- except VolcanoError:
1117
- self._owned_refresh_session(binding)
1144
+ self._rejected_refresh = (generation, owner)
1118
1145
  raise
1119
- if not self._client._set_session_if_current(
1120
- refreshed, generation, event="TOKEN_REFRESHED", notifications=notifications
1121
- ):
1122
- raise SessionChangedError
1146
+ validate_refresh_identity(current, refreshed)
1147
+ owner.verify_pair(refreshed)
1148
+ if owner.signing_out is None:
1149
+ self._client._set_session_if_current(
1150
+ refreshed,
1151
+ generation,
1152
+ event="TOKEN_REFRESHED",
1153
+ notifications=notifications,
1154
+ )
1155
+ return refreshed
1123
1156
 
1124
- def _request_refreshed_session(self, current: Session) -> Session:
1157
+ def _request_refreshed_session(self, refresh_token: str) -> Session:
1125
1158
  transport = cast("AuthRefreshTransport", self._client._transport)
1126
1159
  try:
1127
1160
  response = invoke(
1128
1161
  transport.auth_refresh,
1129
1162
  authorization=self._client._anon_token(),
1130
- refresh_token=current.refresh_token,
1163
+ refresh_token=refresh_token,
1131
1164
  )
1132
1165
  return _session_from_payload(response_payload(response, 200))
1133
1166
  except (KeyError, TypeError, ValueError) as error:
@@ -1135,21 +1168,108 @@ class Auth:
1135
1168
 
1136
1169
  def sign_out(self) -> None:
1137
1170
  """Revoke and clear the current session."""
1138
- generation, current = self._client._capture_session()
1171
+ binding = self._client._capture_session_binding()
1172
+ if binding[2] is None:
1173
+ binding[1].wait_for_sign_out()
1174
+ return
1175
+ notifications: list[Callable[[], None]] = []
1176
+ try:
1177
+ binding[1].sign_out(
1178
+ lambda preceding, pending: self._sign_out_captured(
1179
+ binding, preceding, notifications, pending=pending
1180
+ )
1181
+ )
1182
+ finally:
1183
+ for dispatch in notifications:
1184
+ dispatch()
1185
+
1186
+ def _sign_out_captured(
1187
+ self,
1188
+ binding: tuple[int, SessionOperations, Session | None],
1189
+ preceding: Future[Session] | None,
1190
+ notifications: list[Callable[[], None]],
1191
+ *,
1192
+ pending: bool,
1193
+ ) -> None:
1194
+ generation, owner, current = binding
1195
+ refresh_error: VolcanoError | None = None
1196
+ if preceding is not None:
1197
+ try:
1198
+ current = preceding.result()
1199
+ except VolcanoError as caught:
1200
+ refresh_error = caught
1139
1201
  if current is None:
1140
1202
  return
1141
- transport = cast("AuthLogoutTransport", self._client._transport)
1142
1203
  error: VolcanoError | None = None
1143
1204
  try:
1144
- response = invoke(
1145
- transport.auth_logout,
1146
- authorization=self._client._anon_token(),
1147
- refresh_token=current.refresh_token,
1205
+ self._revoke_session(
1206
+ current, owner, refresh_error if pending else None, joined=pending
1148
1207
  )
1149
- response_payload(response, 204)
1150
1208
  except VolcanoError as caught:
1151
1209
  error = caught
1152
- if not self._client._clear_session_if_current(generation):
1210
+ if not self._client._clear_session_if_current(
1211
+ generation, lineage=owner, notifications=notifications
1212
+ ):
1153
1213
  raise SessionChangedError from error
1154
1214
  if error is not None:
1155
1215
  raise error
1216
+
1217
+ def _revoke_session(
1218
+ self,
1219
+ session: Session,
1220
+ owner: SessionOperations,
1221
+ refresh_error: VolcanoError | None,
1222
+ *,
1223
+ joined: bool,
1224
+ ) -> None:
1225
+ session_id = session_id_from_access_token(session.access_token)
1226
+ verified = owner.has_verified_pair(session)
1227
+ if session_id is not None and not verified:
1228
+ self._revoke_access_session(
1229
+ session, session_id, refresh_error, joined=joined
1230
+ )
1231
+ return
1232
+ if refresh_error is not None and not verified:
1233
+ raise refresh_error
1234
+ if session.refresh_token is not None:
1235
+ transport = cast("AuthLogoutTransport", self._client._transport)
1236
+ response = invoke(
1237
+ transport.auth_logout,
1238
+ authorization=self._client._anon_token(),
1239
+ refresh_token=session.refresh_token,
1240
+ )
1241
+ else:
1242
+ return
1243
+ response_payload(response, 204)
1244
+
1245
+ def _revoke_access_session(
1246
+ self,
1247
+ session: Session,
1248
+ session_id: str,
1249
+ refresh_error: VolcanoError | None,
1250
+ *,
1251
+ joined: bool,
1252
+ ) -> None:
1253
+ transport = cast("AuthDeleteMySessionTransport", self._client._transport)
1254
+ response = invoke(
1255
+ transport.auth_delete_my_session,
1256
+ authorization=session.access_token,
1257
+ session_id=session_id,
1258
+ )
1259
+ if (
1260
+ response.status_code == HTTPStatus.UNAUTHORIZED
1261
+ and session.refresh_token is not None
1262
+ ):
1263
+ if refresh_error is not None:
1264
+ raise refresh_error
1265
+ if joined:
1266
+ response_payload(response, 204)
1267
+ return
1268
+ refreshed = self._request_refreshed_session(session.refresh_token)
1269
+ validate_refresh_identity(session, refreshed)
1270
+ response = invoke(
1271
+ transport.auth_delete_my_session,
1272
+ authorization=refreshed.access_token,
1273
+ session_id=session_id,
1274
+ )
1275
+ response_payload(response, 204)
volcano_sdk/client.py CHANGED
@@ -5,9 +5,11 @@ from __future__ import annotations
5
5
  import threading
6
6
  from collections import deque
7
7
  from dataclasses import replace
8
- from typing import TYPE_CHECKING
8
+ from typing import TYPE_CHECKING, TypedDict, Unpack
9
9
  from uuid import UUID
10
10
 
11
+ from ._session import validate_refresh_identity
12
+ from ._session_operations import SessionOperations
11
13
  from ._transport import GeneratedTransport, Transport
12
14
  from .auth import Auth
13
15
  from .database import Database
@@ -32,6 +34,39 @@ if TYPE_CHECKING:
32
34
  _NO_ACTIVE_SESSION = "No active session"
33
35
  _NO_SERVICE_KEY = "No service key configured"
34
36
  _PROFILE_USER_MISMATCH = "Profile user does not match the active session"
37
+ _BOOTSTRAP_ACCESS_REQUIRED = "refresh_token requires access_token"
38
+
39
+
40
+ class _BootstrapCredentials(TypedDict, total=False):
41
+ access_token: str | None
42
+ refresh_token: str | None
43
+
44
+
45
+ def _validate_bootstrap_credential(name: str, token: object) -> None:
46
+ if token is not None and (not isinstance(token, str) or not token.strip()):
47
+ message = f"{name} must be a non-empty string"
48
+ raise ValueError(message)
49
+
50
+
51
+ def _bootstrap_session(
52
+ credentials: _BootstrapCredentials,
53
+ ) -> Session | None:
54
+ unknown = credentials.keys() - {"access_token", "refresh_token"}
55
+ if unknown:
56
+ message = f"Unexpected keyword argument: {next(iter(unknown))}"
57
+ raise TypeError(message)
58
+ access_token = credentials.get("access_token")
59
+ refresh_token = credentials.get("refresh_token")
60
+ if access_token is None:
61
+ if refresh_token is not None:
62
+ raise ValueError(_BOOTSTRAP_ACCESS_REQUIRED)
63
+ return None
64
+ for name, token in (
65
+ ("access_token", access_token),
66
+ ("refresh_token", refresh_token),
67
+ ):
68
+ _validate_bootstrap_credential(name, token)
69
+ return Session(access_token=access_token, refresh_token=refresh_token)
35
70
 
36
71
 
37
72
  class _CallbackOutcome:
@@ -65,6 +100,7 @@ class VolcanoClient:
65
100
  timeout: float = 60.0,
66
101
  _transport: Transport | None = None,
67
102
  _realtime_client_factory: CentrifugeFactory | None = None,
103
+ **credentials: Unpack[_BootstrapCredentials],
68
104
  ) -> None:
69
105
  """Create a client for a Volcano project."""
70
106
  self._api_url = api_url.rstrip("/")
@@ -72,8 +108,8 @@ class VolcanoClient:
72
108
  self._service_key = service_key
73
109
  self._session_lock = threading.Lock()
74
110
  self._session_generation = 0
75
- self._session_lineage = 0
76
- self._current_session: Session | None = None
111
+ self._session_lineage = SessionOperations()
112
+ self._current_session = _bootstrap_session(credentials)
77
113
  self._auth_callbacks: dict[int, AuthStateCallback] = {}
78
114
  self._next_auth_callback_id = 0
79
115
  self._auth_notifications: deque[
@@ -146,7 +182,7 @@ class VolcanoClient:
146
182
  with self._session_lock:
147
183
  self._current_session = session
148
184
  self._session_generation += 1
149
- self._session_lineage += 1
185
+ self._session_lineage = SessionOperations()
150
186
  if event is None:
151
187
  return
152
188
  callback_ids = tuple(self._auth_callbacks)
@@ -158,7 +194,7 @@ class VolcanoClient:
158
194
  with self._session_lock:
159
195
  return self._session_generation, self._current_session
160
196
 
161
- def _capture_session_binding(self) -> tuple[int, int, Session | None]:
197
+ def _capture_session_binding(self) -> tuple[int, SessionOperations, Session | None]:
162
198
  with self._session_lock:
163
199
  return (
164
200
  self._session_generation,
@@ -173,15 +209,16 @@ class VolcanoClient:
173
209
  current = self._current_session
174
210
  if generation != self._session_generation or current is None:
175
211
  return False
212
+ user_id = str(user["id"]) if current.user_id is None else current.user_id
176
213
  try:
177
- same_user = UUID(str(user["id"])) == UUID(current.user_id)
214
+ same_user = UUID(str(user["id"])) == UUID(user_id)
178
215
  except ValueError:
179
216
  same_user = False
180
217
  if not same_user:
181
218
  raise AuthenticationError(_PROFILE_USER_MISMATCH)
182
219
  # Profile updates do not replace credentials or invalidate other requests.
183
220
  self._current_session = replace(
184
- current, user={**user, "id": current.user_id}
221
+ current, user_id=user_id, user={**user, "id": user_id}
185
222
  )
186
223
  return True
187
224
 
@@ -196,10 +233,12 @@ class VolcanoClient:
196
233
  with self._session_lock:
197
234
  if generation != self._session_generation:
198
235
  return False
236
+ if event == "TOKEN_REFRESHED":
237
+ validate_refresh_identity(self._current_session, session)
199
238
  self._current_session = session
200
239
  self._session_generation += 1
201
240
  if event != "TOKEN_REFRESHED":
202
- self._session_lineage += 1
241
+ self._session_lineage = SessionOperations(session)
203
242
  callback_ids = tuple(self._auth_callbacks)
204
243
  dispatch = self._enqueue_auth_state_change(callback_ids, event, session)
205
244
  if dispatch:
@@ -210,15 +249,21 @@ class VolcanoClient:
210
249
  self,
211
250
  generation: int,
212
251
  *,
252
+ lineage: SessionOperations | None = None,
213
253
  event: AuthChangeEvent = "SIGNED_OUT",
214
254
  notifications: list[Callable[[], None]] | None = None,
215
255
  ) -> bool:
216
256
  with self._session_lock:
217
- if generation != self._session_generation:
257
+ if lineage is not None:
258
+ if lineage != self._session_lineage:
259
+ return False
260
+ elif generation != self._session_generation:
218
261
  return False
262
+ if self._current_session is None:
263
+ return True
219
264
  self._current_session = None
265
+ self._session_lineage.clear_local_credentials()
220
266
  self._session_generation += 1
221
- self._session_lineage += 1
222
267
  callback_ids = tuple(self._auth_callbacks)
223
268
  dispatch = self._enqueue_auth_state_change(callback_ids, event, None)
224
269
  if dispatch:
volcano_sdk/models.py CHANGED
@@ -84,11 +84,11 @@ class User:
84
84
 
85
85
  @dataclass(frozen=True, slots=True)
86
86
  class Session:
87
- """Authenticated user session."""
87
+ """Local credentials with optional refresh credentials and user identity."""
88
88
 
89
89
  access_token: str
90
- refresh_token: str
91
- user_id: str
90
+ refresh_token: str | None = None
91
+ user_id: str | None = None
92
92
  user: Mapping[str, JSONValue] | None = field(default=None, repr=False, hash=False)
93
93
 
94
94
  def __post_init__(self) -> None:
volcano_sdk/realtime.py CHANGED
@@ -25,6 +25,7 @@ from ._transport import (
25
25
  from .models import JSONValue, _freeze_json
26
26
 
27
27
  if TYPE_CHECKING:
28
+ from ._session_operations import SessionOperations
28
29
  from .models import Session
29
30
 
30
31
  MessageCallback = Callable[[Any], Any]
@@ -205,7 +206,7 @@ def _postgres_fetch_config(
205
206
 
206
207
  @dataclass(frozen=True, slots=True)
207
208
  class _PostgresDeliveryIdentity:
208
- session_lineage: int
209
+ session_lineage: SessionOperations | None
209
210
  subscription_epoch: int
210
211
 
211
212
 
@@ -282,7 +283,9 @@ class RealtimeContext(Protocol):
282
283
 
283
284
  def _session_token(self) -> str: ...
284
285
 
285
- def _capture_session_binding(self) -> tuple[int, int, Session | None]: ...
286
+ def _capture_session_binding(
287
+ self,
288
+ ) -> tuple[int, SessionOperations, Session | None]: ...
286
289
 
287
290
 
288
291
  class CentrifugeSubscription(Protocol):
@@ -608,7 +611,7 @@ class Channel:
608
611
  self._callback_task: asyncio.Task[None] | None = None
609
612
  self._pending_presence_sync: Any = NO_PENDING_CALLBACK
610
613
  self._postgres_epoch = 0
611
- self._postgres_session_lineage = 0
614
+ self._postgres_session_lineage: SessionOperations | None = None
612
615
  self._postgres_lock = asyncio.Lock()
613
616
  self._postgres_worker: PostgresFetchWorker[_PostgresDelivery] | None = None
614
617
  self._postgres_filters: dict[
@@ -732,11 +735,12 @@ class Channel:
732
735
  self,
733
736
  identity: _PostgresDeliveryIdentity,
734
737
  ) -> bool:
735
- _generation, lineage, _session = (
738
+ _generation, lineage, session = (
736
739
  self._realtime._client_context._capture_session_binding()
737
740
  )
738
741
  return (
739
742
  self._subscribed
743
+ and session is not None
740
744
  and identity.subscription_epoch == self._postgres_epoch
741
745
  and identity.session_lineage == lineage
742
746
  )
@@ -1161,7 +1165,7 @@ class Realtime:
1161
1165
  self._api_url = api_url
1162
1166
  self._client_factory = client_factory
1163
1167
  self._connection: _VolcanoCentrifugeConnection | None = None
1164
- self._connection_session_lineage: int | None = None
1168
+ self._connection_session_lineage: SessionOperations | None = None
1165
1169
  self._connection_access_token: str | None = None
1166
1170
  self._connection_lock = asyncio.Lock()
1167
1171
  self._channels: dict[str, Channel] = {}
@@ -1401,7 +1405,7 @@ class Realtime:
1401
1405
  self._connection_access_token = session.access_token
1402
1406
  return session.access_token
1403
1407
 
1404
- def _session_for_lineage(self, expected_lineage: int) -> Session:
1408
+ def _session_for_lineage(self, expected_lineage: SessionOperations) -> Session:
1405
1409
  _generation, lineage, session = self._client_context._capture_session_binding()
1406
1410
  if session is None:
1407
1411
  raise RuntimeError(NO_ACTIVE_SESSION)
@@ -1409,7 +1413,7 @@ class Realtime:
1409
1413
  raise RuntimeError(CONNECTION_SESSION_CHANGED)
1410
1414
  return session
1411
1415
 
1412
- def _connection_lineage(self) -> int:
1416
+ def _connection_lineage(self) -> SessionOperations:
1413
1417
  lineage = self._connection_session_lineage
1414
1418
  if lineage is None:
1415
1419
  raise RuntimeError(CONNECTION_SESSION_UNAVAILABLE)
volcano_sdk/storage.py CHANGED
@@ -35,6 +35,7 @@ from .models import (
35
35
  )
36
36
 
37
37
  if TYPE_CHECKING:
38
+ from ._session_operations import SessionOperations
38
39
  from .auth import Auth
39
40
 
40
41
  _INVALID_STORAGE_PAGE = "Expected a complete storage page"
@@ -292,7 +293,9 @@ class StorageContext(Protocol):
292
293
 
293
294
  def _session_token(self) -> str: ...
294
295
 
295
- def _capture_session_binding(self) -> tuple[int, int, Session | None]: ...
296
+ def _capture_session_binding(
297
+ self,
298
+ ) -> tuple[int, SessionOperations, Session | None]: ...
296
299
 
297
300
 
298
301
  class StorageListTransport(Protocol):
@@ -707,7 +710,9 @@ class StorageBucket:
707
710
  self._remove_path(path, binding)
708
711
  return path_list
709
712
 
710
- def _remove_path(self, path: str, binding: tuple[int, int, Session | None]) -> None:
713
+ def _remove_path(
714
+ self, path: str, binding: tuple[int, SessionOperations, Session | None]
715
+ ) -> None:
711
716
  transport = cast("StorageDeleteTransport", self._client._transport)
712
717
  response = self._client.auth._session_request(
713
718
  lambda token: invoke(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: volcano-sdk-python
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Official Python SDK for Volcano
5
5
  Project-URL: Homepage, https://volcano.dev
6
6
  Project-URL: Repository, https://github.com/Kong/volcano-sdk-python
@@ -593,6 +593,14 @@ Success returns `None`. The reset revokes the recovered account's existing sessi
593
593
  sign it in. The client keeps any unrelated local session unchanged; sign in with the new password
594
594
  when the reset flow completes.
595
595
 
596
+ To start with only a supplied user access token, pass `access_token` to
597
+ `VolcanoClient`. Construction makes no request and leaves `refresh_token`,
598
+ `user_id`, and `user` as `None` until supplied or validated by the server.
599
+ `get_user()` validates and caches the profile without changing credentials.
600
+ Without a refresh token, `refresh_session()` raises `AuthenticationError` and
601
+ `sign_out()` revokes the server session using the access token and clears local state.
602
+ Supply `refresh_token` with `access_token` to enable refresh. See the [token bootstrap example](https://github.com/Kong/volcano-sdk-python/blob/main/docs/README.md#use-a-supplied-access-token).
603
+
596
604
  Copy a complete native session into another client's memory:
597
605
 
598
606
  ```python
@@ -650,9 +658,13 @@ client.auth.sign_out()
650
658
  assert client.auth.get_session() is None
651
659
  ```
652
660
 
661
+ Sign-out uses the refresh token directly when the SDK received both credentials together from
662
+ sign-in or a validated refresh. Supplied credentials use the access-token session; on HTTP 401,
663
+ the SDK can refresh once and revoke that same session without adopting the renewed credentials.
653
664
  Calling `sign_out()` without a session succeeds without a request. A revocation failure is raised
654
- after the captured local session is cleared. A newer session established while sign-out is in
655
- flight remains current.
665
+ after the captured local session is cleared. Sign-out waits for an already-running refresh and uses its validated credentials.
666
+ Later refresh attempts raise `SessionChangedError` without a request. Concurrent sign-out calls
667
+ share one result. A separate sign-in or adoption remains current.
656
668
 
657
669
  Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
658
670
  subscription objects are not part of the public API.
@@ -4,19 +4,21 @@ volcano_sdk/_lock_guard.py,sha256=VhHTgBc4e5EDj8j15vVIhdBOTp6fvD628x3hVBeF39o,57
4
4
  volcano_sdk/_lock_renewer.py,sha256=tFo1ZeRiPKB5VSbW61uoMpNR67ZDYyMEe-dCjpAM3As,841
5
5
  volcano_sdk/_lock_worker.py,sha256=QhSmwR0WcwgT-7ueJMlbCogLqOakQ5eRAtyWV74fgV0,3057
6
6
  volcano_sdk/_realtime_fetch_worker.py,sha256=zQTgFF4cIUBeAL4J2OpWAlz_sIynv2BLfu588EogAdo,9269
7
+ volcano_sdk/_session.py,sha256=LYCv2NCDlL5Slh2YLEG8VQ8z-kDaKDYWW4vGdMvUf1U,2474
8
+ volcano_sdk/_session_operations.py,sha256=1vI9gtwjxG2eUGT-nIYnMEgRldJqgIj2lRLukBbG64E,5518
7
9
  volcano_sdk/_transport.py,sha256=AFd47C3X1WAHwq3f9V2ux0WNzwGIn4NBxrwgaIN-xl0,58296
8
- volcano_sdk/auth.py,sha256=Z5UCrqdvmHBS6n6ZleXya04hYV94rH4SRV1GW2yimXo,43185
9
- volcano_sdk/client.py,sha256=KutjBUOEJyK73OibKilrcWazpWeXKEZYUehSAH8qxUE,10366
10
+ volcano_sdk/auth.py,sha256=iKq8o0K8geVKXrnLzhvOjAQoX_Nd0yQDJqDH4bemWaM,47366
11
+ volcano_sdk/client.py,sha256=CJJnf_iTcXnyY2Y57LXJVAn39mTUsepblUg9op-Thzo,12283
10
12
  volcano_sdk/connection_string.py,sha256=LlTZigsrCF4IfAqQ8SDTWJv35hnnzNu1MfeVv5JW-Kk,2331
11
13
  volcano_sdk/database.py,sha256=p_5OaqlBjKAtIUfKQCflXM_CGqokrVmpOMsp6KHiJ8A,9493
12
14
  volcano_sdk/errors.py,sha256=ffs66FIN_jhyh5SMj7KdXb-zVV3DSgVK6xJEPNA5h9Y,1613
13
15
  volcano_sdk/functions.py,sha256=zDeAKyCieNnI6wzb9iZMlg9_cbjaYWXYbXHb7jzCle4,10126
14
16
  volcano_sdk/locks.py,sha256=eSkidfHQ_wml5-1LT2hBtHxeALP6E1cGYK0FzHjbrNk,9241
15
17
  volcano_sdk/logs.py,sha256=lxORRQBvsnJ0BdMau19MXrIeig5_mEucQSyJ4F1CZxU,4666
16
- volcano_sdk/models.py,sha256=43zM6LQ9eJ3Hn6q9PiVSozSmOzPSr4m4OL8G8KVb-gg,8619
18
+ volcano_sdk/models.py,sha256=n5EdzkWzmOstXsuFlvfGOILRemQAqG2P4VCLMelW8SA,8690
17
19
  volcano_sdk/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
18
- volcano_sdk/realtime.py,sha256=VkxZyrC7PlddnCSv8wBBLVg4nPWjpyuGo1FoBWhpf6M,59295
19
- volcano_sdk/storage.py,sha256=1bAgzGO-dhUCVG8B-5x40jenHeh68Id2csIJF57KSGk,26126
20
+ volcano_sdk/realtime.py,sha256=3p5ueLCeveaR4_ItqlLv-exkMCODqxDQNHj1aYB_YE4,59506
21
+ volcano_sdk/storage.py,sha256=p16OV5ADI6JjMAJ7gx2gcmwk9t8--YiIZcOSLQLVlDY,26238
20
22
  volcano_sdk/_generated/__init__.py,sha256=ZlB8wMYdN6EjpUS5W_lX3jsxq9G1kOcmUr-j3Fccfn8,164
21
23
  volcano_sdk/_generated/client.py,sha256=GDzw6T2uRmPlUb1DImpBGxwy0dNbhmBknfvvIQW0GuU,12387
22
24
  volcano_sdk/_generated/errors.py,sha256=trp-p5qn1_JLRxGZhdHtICaNPaCrcDCe4TgIihBravk,546
@@ -773,7 +775,7 @@ volcano_sdk/_generated/models/usage_data_point.py,sha256=d4U08JbVTiIMM_l2BmRILL1
773
775
  volcano_sdk/_generated/models/variable.py,sha256=tvGDOMv77gW6j5skufnfrlNAhzCzA1F7nuPCQWx79yY,5926
774
776
  volcano_sdk/_generated/models/variable_deploy_source.py,sha256=G_aCiFHzQx8WhJFs0GvYZEFclfTrfkDvd3kT1e3vty8,463
775
777
  volcano_sdk/_generated/models/variable_status.py,sha256=THSsKBSiAYG4kAtBzGA4TZqxjgx8hdL0Q4qCdZfOj00,391
776
- volcano_sdk_python-0.8.0.dist-info/METADATA,sha256=lJ43KIGGSaMGAsRRM_EqJYI9AJXnEibMrg9CqElZU78,34010
777
- volcano_sdk_python-0.8.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
778
- volcano_sdk_python-0.8.0.dist-info/licenses/LICENSE,sha256=AIHJy-5MFqM8fHfuGFTTb9a8rvJGypcVgN4aZ-7XTWw,11387
779
- volcano_sdk_python-0.8.0.dist-info/RECORD,,
778
+ volcano_sdk_python-0.9.1.dist-info/METADATA,sha256=zPhzqaYqvRyeIoZlgMAlmRbvskDedIxy6kclHDME__4,35125
779
+ volcano_sdk_python-0.9.1.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
780
+ volcano_sdk_python-0.9.1.dist-info/licenses/LICENSE,sha256=AIHJy-5MFqM8fHfuGFTTb9a8rvJGypcVgN4aZ-7XTWw,11387
781
+ volcano_sdk_python-0.9.1.dist-info/RECORD,,