Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,17 @@ snapshot. An authentication failure clears the session that initiated the reques
transport failures preserve it, and a late response never replaces a newer session. The SDK does
not persist sessions.

Sign out by revoking and clearing the current session:

```python
client.auth.sign_out()
assert client.auth.get_session() is None
```

Calling `sign_out()` without a session succeeds without a request. A revocation failure is raised
after the captured local session is cleared. A newer session established while sign-out is in
flight remains current.

Realtime is async. Channels wrap `centrifuge-python`; the underlying client and
subscription objects are not part of the public API.

Expand Down
11 changes: 11 additions & 0 deletions features/contract/auth.feature
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,14 @@ Feature: SDK authentication contract
And the refreshed session replaces the previous credentials
And the current session belongs to the contract user
And the current session exposes access and refresh tokens

@auth @SDK-AUTH-005
Scenario: A client signs out its current session
Given the confirmed contract user
When the client signs in with the contract user's credentials
And the client signs out
Then the SDK operation succeeds
And the current session is empty
When a fresh client tries to refresh the signed-out session
Then the SDK operation fails with an authentication error
And the current session is empty
1 change: 1 addition & 0 deletions features/contract_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ def __init__(self, fixture: dict[str, Any]) -> None:
}
self.last_outcome: Outcome | None = None
self.previous_session: Session | None = None
self.signed_out_session: Session | None = None
self.subscriber: Channel | None = None
self.publisher: Channel | None = None
self.realtime_clients: list[VolcanoClient] = []
Expand Down
34 changes: 34 additions & 0 deletions features/steps/sdk_contract_steps.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,40 @@ def refreshed_session_replaces_credentials(context: Any) -> None:
)


@when("the client signs out")
def sign_out(context: Any) -> None:
world = _world(context)
world.signed_out_session = world.client.auth.get_session()
assert world.signed_out_session is not None
world.record(world.client.auth.sign_out)


@then("the current session is empty")
def current_session_is_empty(context: Any) -> None:
assert _world(context).client.auth.get_session() is None


@when("a fresh client tries to refresh the signed-out session")
def refresh_signed_out_session(context: Any) -> None:
world = _world(context)
assert world.signed_out_session is not None
target = VolcanoClient(
api_url=world.fixture["api_url"],
anon_key=world.fixture["anon_key"],
)
target.auth.set_session(world.signed_out_session)
world.client = target
world.record(target.auth.refresh_session)


@then("the SDK operation fails with an authentication error")
def operation_fails_with_authentication_error(context: Any) -> None:
outcome = _world(context).last_outcome
assert outcome is not None
assert not outcome.ok
assert outcome.category == "authentication error"


@then("the SDK operation succeeds")
def operation_succeeds(context: Any) -> None:
outcome = _world(context).last_outcome
Expand Down
25 changes: 24 additions & 1 deletion src/volcano_sdk/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,15 @@

import httpx

from ._generated.api.authentication import auth_refresh, auth_signin
from ._generated.api.authentication import auth_logout, auth_refresh, auth_signin
from ._generated.api.database_queries import query_database_select
from ._generated.api.locks import acquire_project_lock, release_project_lock
from ._generated.api.storage_objects import (
download_storage_object,
upload_storage_object,
)
from ._generated.client import AuthenticatedClient
from ._generated.models.auth_logout_body import AuthLogoutBody
from ._generated.models.auth_refresh_body import AuthRefreshBody
from ._generated.models.auth_signin_body import AuthSigninBody
from ._generated.models.database_select_request import DatabaseSelectRequest
Expand Down Expand Up @@ -88,6 +89,15 @@ def auth_refresh(
) -> TransportResponse: ...


class AuthLogoutTransport(Protocol):
def auth_logout(
self,
*,
authorization: str,
refresh_token: str,
) -> TransportResponse: ...


class Transport(Protocol):
def auth_signin(
self,
Expand Down Expand Up @@ -266,6 +276,19 @@ def auth_refresh(
)
return self._response(response)

def auth_logout(
self,
*,
authorization: str,
refresh_token: str,
) -> TransportResponse:
with self._client(authorization) as client:
response = auth_logout.sync_detailed(
client=client,
body=AuthLogoutBody(refresh_token=refresh_token),
)
return self._response(response)

def query_database_select(
self,
*,
Expand Down
31 changes: 29 additions & 2 deletions src/volcano_sdk/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@
from collections.abc import Mapping
from typing import Protocol, cast

from ._transport import AuthRefreshTransport, Transport, invoke, response_payload
from .errors import AuthenticationError, SessionChangedError
from ._transport import (
AuthLogoutTransport,
AuthRefreshTransport,
Transport,
invoke,
response_payload,
)
from .errors import AuthenticationError, SessionChangedError, VolcanoError
from .models import Session

_INCOMPLETE_SESSION = "Expected a complete Session"
Expand Down Expand Up @@ -129,3 +135,24 @@ def refresh_session(self) -> Session:
if not self._client._set_session_if_current(refreshed, generation):
raise SessionChangedError
return refreshed

def sign_out(self) -> None:
"""Revoke and clear the current session."""
generation, current = self._client._capture_session()
if current is None:
return
transport = cast("AuthLogoutTransport", self._client._transport)
error: VolcanoError | None = None
try:
response = invoke(
transport.auth_logout,
authorization=self._client._anon_token(),
refresh_token=current.refresh_token,
)
response_payload(response, 204)
except VolcanoError as caught:
error = caught
if not self._client._clear_session_if_current(generation):
raise SessionChangedError from error
if error is not None:
raise error
6 changes: 5 additions & 1 deletion tests/unit/test_contract_bindings.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

ROOT = Path(__file__).parents[2]
FEATURE_SHA256 = {
"auth.feature": "ca01037d4f7c40a1d17be3e79ff24d2433c2085e991332f594ca698466c33f8d",
"auth.feature": "681041f0e40df20a8decdf733fdadcc3f6486bc0767d9db2ab81cf8f70bdd5eb",
"database.feature": (
"4685b29357a621068b25984ff0de29cd4c504eebe5cfb597f0b999e29878a668"
),
Expand Down Expand Up @@ -61,6 +61,7 @@ def test_every_contract_phrase_is_bound_verbatim() -> None:
assert bound == {
"a service-role client",
"a fresh client adopts the current session",
"a fresh client tries to refresh the signed-out session",
"an authenticated client",
"exactly the fixture row is returned",
"one client subscribes and the other publishes the contract message",
Expand All @@ -69,14 +70,17 @@ def test_every_contract_phrase_is_bound_verbatim() -> None:
'the client selects the contract table where "slug" equals the fixture slug',
"the client reads the current session",
"the client refreshes the current session",
"the client signs out",
"the client signs in with the contract user's credentials",
"the client uploads and downloads the contract object",
"the confirmed contract user",
"the current session belongs to the contract user",
"the current session exposes access and refresh tokens",
"the current session is empty",
"the downloaded bytes equal the uploaded bytes",
"the refreshed session replaces the previous credentials",
"the released lease is no longer held",
"the SDK operation fails with an authentication error",
"the stored object path equals the contract path",
"the subscriber receives the contract message within 10 seconds",
"two authenticated realtime clients",
Expand Down
24 changes: 24 additions & 0 deletions tests/unit/test_generated_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,30 @@
from volcano_sdk._transport import GeneratedTransport


def test_generated_transport_logs_out_with_the_anon_key_and_refresh_token() -> None:
requests: list[httpx.Request] = []

def handle(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(204)

transport = GeneratedTransport(
api_url="https://api.test.volcano.dev",
httpx_transport=httpx.MockTransport(handle),
)

response = transport.auth_logout(
authorization="anon-key",
refresh_token="refresh-1",
)

assert response.status_code == 204
assert requests[0].method == "POST"
assert requests[0].url.path == "/auth/logout"
assert requests[0].headers["authorization"] == "Bearer anon-key"
assert json.loads(requests[0].content) == {"refresh_token": "refresh-1"}


def test_generated_transport_calls_the_seven_openapi_operations() -> None:
requests: list[httpx.Request] = []

Expand Down
56 changes: 56 additions & 0 deletions tests/unit/test_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ def __init__(self) -> None:
},
)
self.on_refresh: Callable[[], None] | None = None
self.logout_response = Response(204)
self.on_logout: Callable[[], None] | None = None
self.query_calls: list[dict[str, Any]] = []
self.authorizations: list[tuple[str, str]] = []

Expand All @@ -57,6 +59,12 @@ def auth_refresh(self, **kwargs: Any) -> Response:
self.on_refresh()
return self.refresh_response

def auth_logout(self, **kwargs: Any) -> Response:
self.authorizations.append(("logout", kwargs["authorization"]))
if self.on_logout is not None:
self.on_logout()
return self.logout_response

def query_database_select(self, **kwargs: Any) -> Response:
self.authorizations.append(("query", kwargs["authorization"]))
self.query_calls.append(kwargs["body"])
Expand Down Expand Up @@ -282,3 +290,51 @@ def replace_session() -> None:
client.auth.refresh_session()

assert client.auth.get_session() == replacement


def test_sign_out_revokes_and_clears_the_current_session() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)
client.auth.sign_in(email="user@example.com", password="secret")

client.auth.sign_out()

assert client.auth.get_session() is None
assert transport.authorizations[-1] == ("logout", "anon")


def test_sign_out_without_a_session_succeeds_without_transport() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)

client.auth.sign_out()
assert transport.authorizations == []


def test_sign_out_server_failure_clears_then_raises() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)
client.auth.sign_in(email="user@example.com", password="secret")
transport.logout_response = Response(503, {"error": "Logout unavailable"})

with pytest.raises(ServerError, match="Logout unavailable"):
client.auth.sign_out()

assert client.auth.get_session() is None


def test_sign_out_does_not_clear_a_replacement_session() -> None:
transport = StateTransport()
client = VolcanoClient(anon_key="anon", _transport=transport)
client.auth.sign_in(email="user@example.com", password="secret")
replacement = Session(
"replacement-access", "replacement-refresh", "replacement-user"
)

def replace_session() -> None:
client.auth.set_session(replacement)

transport.on_logout = replace_session
with pytest.raises(SessionChangedError):
client.auth.sign_out()
assert client.auth.get_session() == replacement
Loading