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.
- volcano_sdk/_session.py +70 -0
- volcano_sdk/_session_operations.py +158 -0
- volcano_sdk/auth.py +280 -160
- volcano_sdk/client.py +55 -10
- volcano_sdk/models.py +3 -3
- volcano_sdk/realtime.py +11 -7
- volcano_sdk/storage.py +7 -2
- {volcano_sdk_python-0.8.0.dist-info → volcano_sdk_python-0.9.1.dist-info}/METADATA +15 -3
- {volcano_sdk_python-0.8.0.dist-info → volcano_sdk_python-0.9.1.dist-info}/RECORD +11 -9
- {volcano_sdk_python-0.8.0.dist-info → volcano_sdk_python-0.9.1.dist-info}/WHEEL +0 -0
- {volcano_sdk_python-0.8.0.dist-info → volcano_sdk_python-0.9.1.dist-info}/licenses/LICENSE +0 -0
volcano_sdk/_session.py
ADDED
|
@@ -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(
|
|
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.
|
|
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
|
-
|
|
608
|
-
if
|
|
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 =
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
624
|
-
if
|
|
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 =
|
|
628
|
-
|
|
629
|
-
|
|
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
|
-
|
|
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
|
-
|
|
654
|
-
if
|
|
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 =
|
|
658
|
-
|
|
659
|
-
|
|
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
|
-
|
|
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
|
-
|
|
668
|
-
if
|
|
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 =
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
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
|
-
|
|
685
|
-
if
|
|
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 =
|
|
689
|
-
|
|
690
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
if
|
|
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 =
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
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
|
-
|
|
798
|
-
if
|
|
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 =
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
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
|
-
|
|
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
|
-
|
|
821
|
-
if
|
|
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 =
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
847
|
-
if
|
|
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 =
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
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
|
-
|
|
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
|
-
|
|
877
|
-
if
|
|
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
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
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
|
-
|
|
929
|
+
if not self._client._clear_session_if_current(generation, lineage=lineage):
|
|
930
|
+
raise SessionChangedError
|
|
919
931
|
else:
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
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,
|
|
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,
|
|
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(
|
|
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,
|
|
1079
|
+
self, binding: tuple[int, SessionOperations, Session | None]
|
|
1066
1080
|
) -> Session:
|
|
1067
|
-
generation,
|
|
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
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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,
|
|
1087
|
-
) -> tuple[int,
|
|
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
|
|
1091
|
-
|
|
1092
|
-
|
|
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,
|
|
1121
|
+
binding: tuple[int, SessionOperations, Session | None],
|
|
1103
1122
|
current: Session,
|
|
1104
1123
|
notifications: list[Callable[[], None]],
|
|
1105
|
-
) ->
|
|
1106
|
-
generation,
|
|
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
|
|
1141
|
+
if owner.signing_out is None and self._client._clear_session_if_current(
|
|
1111
1142
|
generation, notifications=notifications
|
|
1112
1143
|
):
|
|
1113
|
-
|
|
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
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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,
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
1145
|
-
|
|
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(
|
|
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 =
|
|
76
|
-
self._current_session
|
|
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
|
|
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,
|
|
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(
|
|
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":
|
|
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
|
|
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
|
|
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
|
-
"""
|
|
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:
|
|
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(
|
|
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 =
|
|
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,
|
|
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:
|
|
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:
|
|
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) ->
|
|
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(
|
|
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(
|
|
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.
|
|
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.
|
|
655
|
-
|
|
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=
|
|
9
|
-
volcano_sdk/client.py,sha256=
|
|
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=
|
|
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=
|
|
19
|
-
volcano_sdk/storage.py,sha256=
|
|
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.
|
|
777
|
-
volcano_sdk_python-0.
|
|
778
|
-
volcano_sdk_python-0.
|
|
779
|
-
volcano_sdk_python-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|