Skip to content

Commit 7f54bf3

Browse files
authored
feat(auth): refresh current sessions (#6)
1 parent c347372 commit 7f54bf3

12 files changed

Lines changed: 282 additions & 24 deletions

File tree

‎README.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,18 @@ if session is not None:
4545
`set_session()` copies the session without making a request or persisting credentials. It raises
4646
`ValueError` when the session type or any credential field is incomplete.
4747

48+
Refresh the session with its current refresh token:
49+
50+
```python
51+
refreshed = client.auth.refresh_session()
52+
assert client.auth.get_session() is refreshed
53+
```
54+
55+
On success, `refresh_session()` replaces the in-memory session and returns the immutable new
56+
snapshot. An authentication failure clears the session that initiated the request. Server and
57+
transport failures preserve it, and a late response never replaces a newer session. The SDK does
58+
not persist sessions.
59+
4860
Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
4961
subscription objects are not part of the public API.
5062

‎features/contract/auth.feature‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,13 @@ Feature: SDK authentication contract
2525
Then the SDK operation succeeds
2626
And the current session belongs to the contract user
2727
And the current session exposes access and refresh tokens
28+
29+
@auth @SDK-AUTH-004
30+
Scenario: A client refreshes its current session
31+
Given the confirmed contract user
32+
When the client signs in with the contract user's credentials
33+
And the client refreshes the current session
34+
Then the SDK operation succeeds
35+
And the refreshed session replaces the previous credentials
36+
And the current session belongs to the contract user
37+
And the current session exposes access and refresh tokens

‎features/contract_support.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
if TYPE_CHECKING:
2424
from collections.abc import Awaitable, Callable
2525

26-
from volcano_sdk.models import LockLease
26+
from volcano_sdk.models import LockLease, Session
2727
from volcano_sdk.realtime import Channel
2828

2929
HTTP_NOT_FOUND = 404
@@ -114,6 +114,7 @@ def __init__(self, fixture: dict[str, Any]) -> None:
114114
"value": f"volcano-sdk-contract-{suffix}",
115115
}
116116
self.last_outcome: Outcome | None = None
117+
self.previous_session: Session | None = None
117118
self.subscriber: Channel | None = None
118119
self.publisher: Channel | None = None
119120
self.realtime_clients: list[VolcanoClient] = []

‎features/steps/sdk_contract_steps.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,24 @@ def adopt_current_session(context: Any) -> None:
5353
world.client = target
5454

5555

56+
@when("the client refreshes the current session")
57+
def refresh_current_session(context: Any) -> None:
58+
world = _world(context)
59+
world.previous_session = world.client.auth.get_session()
60+
assert world.previous_session is not None
61+
world.record(world.client.auth.refresh_session)
62+
63+
64+
@then("the refreshed session replaces the previous credentials")
65+
def refreshed_session_replaces_credentials(context: Any) -> None:
66+
world = _world(context)
67+
assert world.last_outcome is not None
68+
assert world.previous_session is not None
69+
assert (
70+
world.last_outcome.value.refresh_token != world.previous_session.refresh_token
71+
)
72+
73+
5674
@then("the SDK operation succeeds")
5775
def operation_succeeds(context: Any) -> None:
5876
outcome = _world(context).last_outcome

‎src/volcano_sdk/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
NotFoundError,
88
RateLimitedError,
99
ServerError,
10+
SessionChangedError,
1011
TransportError,
1112
ValidationError,
1213
VolcanoError,
@@ -21,6 +22,7 @@
2122
"RateLimitedError",
2223
"ServerError",
2324
"Session",
25+
"SessionChangedError",
2426
"TransportError",
2527
"ValidationError",
2628
"VolcanoClient",

‎src/volcano_sdk/_transport.py‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,15 @@
1111

1212
import httpx
1313

14-
from ._generated.api.authentication import auth_signin
14+
from ._generated.api.authentication import auth_refresh, auth_signin
1515
from ._generated.api.database_queries import query_database_select
1616
from ._generated.api.locks import acquire_project_lock, release_project_lock
1717
from ._generated.api.storage_objects import (
1818
download_storage_object,
1919
upload_storage_object,
2020
)
2121
from ._generated.client import AuthenticatedClient
22+
from ._generated.models.auth_refresh_body import AuthRefreshBody
2223
from ._generated.models.auth_signin_body import AuthSigninBody
2324
from ._generated.models.database_select_request import DatabaseSelectRequest
2425
from ._generated.models.project_lock_lease_request import ProjectLockLeaseRequest
@@ -78,6 +79,15 @@ class _GeneratedTransportResponse:
7879
headers: Mapping[str, str]
7980

8081

82+
class AuthRefreshTransport(Protocol):
83+
def auth_refresh(
84+
self,
85+
*,
86+
authorization: str,
87+
refresh_token: str,
88+
) -> TransportResponse: ...
89+
90+
8191
class Transport(Protocol):
8292
def auth_signin(
8393
self,
@@ -243,6 +253,19 @@ def auth_signin(
243253
)
244254
return self._response(response)
245255

256+
def auth_refresh(
257+
self,
258+
*,
259+
authorization: str,
260+
refresh_token: str,
261+
) -> TransportResponse:
262+
with self._client(authorization) as client:
263+
response = auth_refresh.sync_detailed(
264+
client=client,
265+
body=AuthRefreshBody(refresh_token=refresh_token),
266+
)
267+
return self._response(response)
268+
246269
def query_database_select(
247270
self,
248271
*,

‎src/volcano_sdk/auth.py‎

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,15 @@
22

33
from __future__ import annotations
44

5-
from typing import Protocol
5+
from collections.abc import Mapping
6+
from typing import Protocol, cast
67

7-
from ._transport import Transport, invoke, response_payload
8+
from ._transport import AuthRefreshTransport, Transport, invoke, response_payload
9+
from .errors import AuthenticationError, SessionChangedError
810
from .models import Session
911

1012
_INCOMPLETE_SESSION = "Expected a complete Session"
13+
_NO_ACTIVE_SESSION = "No active session"
1114

1215

1316
def _is_non_empty_string(value: object) -> bool:
@@ -35,6 +38,25 @@ def _copy_complete_session(session: object) -> Session:
3538
)
3639

3740

41+
def _session_from_payload(payload: object) -> Session:
42+
values: Mapping[object, object] = (
43+
cast("Mapping[object, object]", payload) if isinstance(payload, Mapping) else {}
44+
)
45+
raw_user = values.get("user")
46+
user: Mapping[object, object] = (
47+
cast("Mapping[object, object]", raw_user)
48+
if isinstance(raw_user, Mapping)
49+
else {}
50+
)
51+
return _copy_complete_session(
52+
Session(
53+
access_token=cast("str", values.get("access_token")),
54+
refresh_token=cast("str", values.get("refresh_token")),
55+
user_id=cast("str", user.get("id")),
56+
)
57+
)
58+
59+
3860
class AuthContext(Protocol):
3961
"""Client capabilities required by the authentication facade."""
4062

@@ -49,6 +71,12 @@ def _anon_token(self) -> str: ...
4971

5072
def _set_session(self, session: Session) -> None: ...
5173

74+
def _capture_session(self) -> tuple[int, Session | None]: ...
75+
76+
def _set_session_if_current(self, session: Session, generation: int) -> bool: ...
77+
78+
def _clear_session_if_current(self, generation: int) -> bool: ...
79+
5280

5381
class Auth:
5482
"""Authenticate users and update the client session."""
@@ -76,10 +104,28 @@ def sign_in(self, *, email: str, password: str) -> Session:
76104
password=password,
77105
)
78106
payload = response_payload(response, 200)
79-
session = Session(
80-
access_token=payload["access_token"],
81-
refresh_token=payload["refresh_token"],
82-
user_id=payload["user"]["id"],
83-
)
107+
session = _session_from_payload(payload)
84108
self._client._set_session(session)
85109
return session
110+
111+
def refresh_session(self) -> Session:
112+
"""Refresh and replace the current session."""
113+
generation, current = self._client._capture_session()
114+
if current is None:
115+
raise AuthenticationError(_NO_ACTIVE_SESSION)
116+
117+
transport = cast("AuthRefreshTransport", self._client._transport)
118+
response = invoke(
119+
transport.auth_refresh,
120+
authorization=self._client._anon_token(),
121+
refresh_token=current.refresh_token,
122+
)
123+
try:
124+
payload = response_payload(response, 200)
125+
except AuthenticationError:
126+
self._client._clear_session_if_current(generation)
127+
raise
128+
refreshed = _session_from_payload(payload)
129+
if not self._client._set_session_if_current(refreshed, generation):
130+
raise SessionChangedError
131+
return refreshed

‎src/volcano_sdk/client.py‎

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from __future__ import annotations
44

5+
import threading
56
from typing import TYPE_CHECKING
67

78
from ._transport import GeneratedTransport, Transport
@@ -35,6 +36,8 @@ def __init__(
3536
self._api_url = api_url.rstrip("/")
3637
self._anon_key = anon_key
3738
self._service_key = service_key
39+
self._session_lock = threading.Lock()
40+
self._session_generation = 0
3841
self._current_session: Session | None = None
3942
self._transport: Transport = (
4043
_transport
@@ -56,7 +59,7 @@ def __init__(
5659
@property
5760
def current_session(self) -> Session | None:
5861
"""Return the authenticated session, if one exists."""
59-
return self._current_session
62+
return self._capture_session()[1]
6063

6164
def database(self, name: str) -> Database:
6265
"""Create a query facade for a project database."""
@@ -66,14 +69,37 @@ def _anon_token(self) -> str:
6669
return self._anon_key
6770

6871
def _session_token(self) -> str:
69-
if self._current_session is None:
72+
session = self._capture_session()[1]
73+
if session is None:
7074
raise RuntimeError(_NO_ACTIVE_SESSION)
71-
return self._current_session.access_token
75+
return session.access_token
7276

7377
def _service_token(self) -> str:
7478
if self._service_key is None:
7579
raise RuntimeError(_NO_SERVICE_KEY)
7680
return self._service_key
7781

7882
def _set_session(self, session: Session) -> None:
79-
self._current_session = session
83+
with self._session_lock:
84+
self._current_session = session
85+
self._session_generation += 1
86+
87+
def _capture_session(self) -> tuple[int, Session | None]:
88+
with self._session_lock:
89+
return self._session_generation, self._current_session
90+
91+
def _set_session_if_current(self, session: Session, generation: int) -> bool:
92+
with self._session_lock:
93+
if generation != self._session_generation:
94+
return False
95+
self._current_session = session
96+
self._session_generation += 1
97+
return True
98+
99+
def _clear_session_if_current(self, generation: int) -> bool:
100+
with self._session_lock:
101+
if generation != self._session_generation:
102+
return False
103+
self._current_session = None
104+
self._session_generation += 1
105+
return True

‎src/volcano_sdk/errors.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,18 @@ class ConflictError(VolcanoError):
3737
"""The request conflicts with the current resource state."""
3838

3939

40+
class SessionChangedError(ConflictError):
41+
"""An auth operation completed after the client session changed."""
42+
43+
def __init__(self) -> None:
44+
"""Create a deterministic stale-auth-operation error."""
45+
super().__init__(
46+
"Session changed during authentication operation",
47+
status=409,
48+
code="auth_session_changed",
49+
)
50+
51+
4052
class RateLimitedError(VolcanoError):
4153
"""The API rejected the request because of a rate limit."""
4254

‎tests/unit/test_contract_bindings.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
ROOT = Path(__file__).parents[2]
1717
FEATURE_SHA256 = {
18-
"auth.feature": "4145af3f1120331d100d8548b2abfcb98c0aeef552fd2b3304dc7e074e526ce2",
18+
"auth.feature": "ca01037d4f7c40a1d17be3e79ff24d2433c2085e991332f594ca698466c33f8d",
1919
"database.feature": (
2020
"4685b29357a621068b25984ff0de29cd4c504eebe5cfb597f0b999e29878a668"
2121
),
@@ -68,12 +68,14 @@ def test_every_contract_phrase_is_bound_verbatim() -> None:
6868
"the client acquires and releases the contract lock",
6969
'the client selects the contract table where "slug" equals the fixture slug',
7070
"the client reads the current session",
71+
"the client refreshes the current session",
7172
"the client signs in with the contract user's credentials",
7273
"the client uploads and downloads the contract object",
7374
"the confirmed contract user",
7475
"the current session belongs to the contract user",
7576
"the current session exposes access and refresh tokens",
7677
"the downloaded bytes equal the uploaded bytes",
78+
"the refreshed session replaces the previous credentials",
7779
"the released lease is no longer held",
7880
"the stored object path equals the contract path",
7981
"the subscriber receives the contract message within 10 seconds",

0 commit comments

Comments
 (0)