From 1b48e8dd8f986a73197121ecedec0ec8daf1e7d4 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:51:48 -0400 Subject: [PATCH 01/37] feat(auth): add Python authentication state --- src/volcano_sdk/__init__.py | 27 +++++++- src/volcano_sdk/client.py | 40 ++++++++++-- src/volcano_sdk/models.py | 114 ++++++++++++++++++++++++++++++-- tests/unit/test_auth.py | 126 ++++++++++++++++++++++++++++++++++++ tests/unit/test_import.py | 28 ++++++++ tests/unit/test_state.py | 60 ++++++++++++++++- 6 files changed, 384 insertions(+), 11 deletions(-) create mode 100644 tests/unit/test_auth.py diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index ab9a77cb..69ed1c82 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -11,17 +11,42 @@ ValidationError, VolcanoError, ) -from .models import LockLease, Session +from .models import ( + AuthorizationRequest, + AuthSession, + EmailChangeResult, + JSONValue, + LockLease, + MessageResult, + OAuthProvider, + OAuthProviderName, + OAuthTokenResult, + Session, + SessionPage, + SignUpResult, + User, +) __all__ = [ + "AuthSession", "AuthenticationError", + "AuthorizationRequest", "ConflictError", + "EmailChangeResult", + "JSONValue", "LockLease", + "MessageResult", "NotFoundError", + "OAuthProvider", + "OAuthProviderName", + "OAuthTokenResult", "RateLimitedError", "ServerError", "Session", + "SessionPage", + "SignUpResult", "TransportError", + "User", "ValidationError", "VolcanoClient", "VolcanoError", diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 33c18ad9..864c5c79 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -2,20 +2,24 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TypedDict, Unpack from ._transport import GeneratedTransport, Transport from .auth import Auth from .database import Database from .locks import Locks +from .models import Session, User from .realtime import CentrifugeFactory, Realtime from .storage import Storage -if TYPE_CHECKING: - from .models import Session - _NO_ACTIVE_SESSION = "No active session" _NO_SERVICE_KEY = "No service key configured" +_REFRESH_WITHOUT_ACCESS = "refresh_token requires access_token" + + +class _AuthBootstrap(TypedDict, total=False): + access_token: str | None + refresh_token: str | None class VolcanoClient: @@ -30,12 +34,22 @@ def __init__( timeout: float = 60.0, _transport: Transport | None = None, _realtime_client_factory: CentrifugeFactory | None = None, + **auth_bootstrap: Unpack[_AuthBootstrap], ) -> None: """Create a client for a Volcano project.""" self._api_url = api_url.rstrip("/") self._anon_key = anon_key self._service_key = service_key - self._current_session: Session | None = None + access_token = auth_bootstrap.get("access_token") + refresh_token = auth_bootstrap.get("refresh_token") + if access_token is None and refresh_token is not None: + raise ValueError(_REFRESH_WITHOUT_ACCESS) + self._current_session = ( + Session(access_token=access_token, refresh_token=refresh_token) + if access_token is not None + else None + ) + self._current_user: User | None = None self._transport: Transport = ( _transport if _transport is not None @@ -58,6 +72,11 @@ def current_session(self) -> Session | None: """Return the authenticated session, if one exists.""" return self._current_session + @property + def current_user(self) -> User | None: + """Return the authenticated user, if one has been loaded.""" + return self._current_user + def database(self, name: str) -> Database: """Create a query facade for a project database.""" return Database(self, name) @@ -77,3 +96,14 @@ def _service_token(self) -> str: def _set_session(self, session: Session) -> None: self._current_session = session + + def _commit_auth(self, session: Session, user: User) -> None: + self._current_session = session + self._current_user = user + + def _set_user(self, user: User) -> None: + self._current_user = user + + def _clear_auth(self) -> None: + self._current_session = None + self._current_user = None diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 8ff7275b..58610f31 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -2,20 +2,126 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Literal, TypeAlias if TYPE_CHECKING: from datetime import datetime +JSONValue: TypeAlias = ( + str | int | float | bool | list["JSONValue"] | dict[str, "JSONValue"] | None +) +OAuthProviderName: TypeAlias = Literal["google", "github", "microsoft", "apple"] + + +@dataclass(frozen=True, slots=True) +class User: + """Authenticated Volcano user.""" + + id: str + email: str + project_id: str | None = None + email_confirmed: bool | None = None + user_metadata: dict[str, JSONValue] | None = field(default=None, repr=False) + app_metadata: dict[str, JSONValue] | None = field(default=None, repr=False) + avatar_url: str | None = None + status: str | None = None + banned_until: datetime | None = None + last_sign_in_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + @dataclass(frozen=True, slots=True) class Session: """Authenticated user session.""" - access_token: str - refresh_token: str + access_token: str = field(repr=False) + refresh_token: str | None = field(default=None, repr=False) + expires_in: int | None = None + user_id: str | None = None + + +@dataclass(frozen=True, slots=True) +class SignUpResult: + """Result of an email-and-password sign-up request.""" + + confirmation_required: bool + message: str + user: User | None = None + session: Session | None = None + + +@dataclass(frozen=True, slots=True) +class MessageResult: + """Acknowledgement returned by an authentication operation.""" + + message: str + + +@dataclass(frozen=True, slots=True) +class EmailChangeResult: + """Result of an email-change request.""" + + message: str + new_email: str + email_change_token: str | None = field(default=None, repr=False) + + +@dataclass(frozen=True, slots=True) +class AuthorizationRequest: + """Authorization URL and state for a hosted authentication flow.""" + + authorization_url: str = field(repr=False) + state: str = field(repr=False) + + +@dataclass(frozen=True, slots=True) +class OAuthProvider: + """OAuth provider linked to the current user.""" + + provider: OAuthProviderName + linked_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class OAuthTokenResult: + """Acknowledgement for an OAuth provider-token operation.""" + + provider: OAuthProviderName + expires_in: int | None = None + message: str | None = None + + +@dataclass(frozen=True, slots=True) +class AuthSession: + """Device session associated with the current user.""" + + id: str user_id: str + provider: str + expires_at: datetime + is_active: bool + is_current: bool + user_agent: str | None = None + ip_address: str | None = None + last_ip_address: str | None = None + last_activity_at: datetime | None = None + session_started_at: datetime | None = None + created_at: datetime | None = None + updated_at: datetime | None = None + + +@dataclass(frozen=True, slots=True) +class SessionPage: + """Page of device sessions associated with the current user.""" + + sessions: tuple[AuthSession, ...] = () + total: int | None = None + page: int | None = None + limit: int | None = None + total_pages: int | None = None @dataclass(frozen=True, slots=True) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py new file mode 100644 index 00000000..9f722627 --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from dataclasses import FrozenInstanceError, fields +from datetime import UTC, datetime + +import pytest + +from volcano_sdk import ( + AuthorizationRequest, + AuthSession, + EmailChangeResult, + MessageResult, + OAuthProvider, + OAuthProviderName, + OAuthTokenResult, + Session, + SessionPage, + SignUpResult, + User, +) + + +def test_public_auth_values_are_frozen_and_slotted() -> None: + user = User(id="user-123", email="user@example.com") + session = Session(access_token="access-token") + values = ( + user, + session, + SignUpResult( + confirmation_required=True, + message="Check your email", + user=user, + session=session, + ), + MessageResult(message="Done"), + EmailChangeResult( + message="Check your email", + new_email="new@example.com", + email_change_token="email-change-token", + ), + AuthorizationRequest( + authorization_url="https://auth.example/authorize?state=oauth-state", + state="oauth-state", + ), + OAuthProvider( + provider="github", + linked_at=datetime(2026, 8, 28, tzinfo=UTC), + ), + OAuthTokenResult( + provider="github", + expires_in=3600, + message="Refreshed", + ), + AuthSession( + id="session-123", + user_id="user-123", + provider="email", + expires_at=datetime(2026, 8, 29, tzinfo=UTC), + is_active=True, + is_current=True, + ), + SessionPage( + sessions=(), + total=0, + page=1, + limit=20, + total_pages=0, + ), + ) + + for value in values: + assert not hasattr(value, "__dict__") + first_field = fields(value)[0].name + with pytest.raises(FrozenInstanceError): + setattr(value, first_field, None) + + +def test_public_auth_value_annotations_do_not_expose_generated_models() -> None: + public_values = ( + User, + Session, + SignUpResult, + MessageResult, + EmailChangeResult, + AuthorizationRequest, + OAuthProvider, + OAuthTokenResult, + AuthSession, + SessionPage, + ) + + for value_type in public_values: + annotations = repr(value_type.__annotations__) + assert "volcano_sdk._generated" not in annotations + + +def test_secret_auth_fields_are_absent_from_repr() -> None: + session = Session( + access_token="access-token", + refresh_token="refresh-token", + ) + email_change = EmailChangeResult( + message="Check your email", + new_email="new@example.com", + email_change_token="email-change-token", + ) + authorization = AuthorizationRequest( + authorization_url="https://auth.example/authorize?state=oauth-state", + state="oauth-state", + ) + + assert "access-token" not in repr(session) + assert "refresh-token" not in repr(session) + assert "email-change-token" not in repr(email_change) + assert "oauth-state" not in repr(authorization) + + +def test_oauth_provider_name_accepts_the_supported_providers() -> None: + providers: tuple[OAuthProviderName, ...] = ( + "google", + "github", + "microsoft", + "apple", + ) + + assert providers == ("google", "github", "microsoft", "apple") diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py index 478ebd90..61e9d97e 100644 --- a/tests/unit/test_import.py +++ b/tests/unit/test_import.py @@ -1,5 +1,33 @@ +import volcano_sdk from volcano_sdk import VolcanoClient def test_package_exports_client() -> None: assert VolcanoClient.__name__ == "VolcanoClient" + + +def test_package_exports_the_public_sdk_contract() -> None: + assert volcano_sdk.__all__ == [ + "AuthSession", + "AuthenticationError", + "AuthorizationRequest", + "ConflictError", + "EmailChangeResult", + "JSONValue", + "LockLease", + "MessageResult", + "NotFoundError", + "OAuthProvider", + "OAuthProviderName", + "OAuthTokenResult", + "RateLimitedError", + "ServerError", + "Session", + "SessionPage", + "SignUpResult", + "TransportError", + "User", + "ValidationError", + "VolcanoClient", + "VolcanoError", + ] diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 5e4f12ef..f8724c51 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -3,7 +3,9 @@ from dataclasses import dataclass from typing import Any -from volcano_sdk import VolcanoClient +import pytest + +from volcano_sdk import Session, User, VolcanoClient @dataclass(frozen=True) @@ -53,6 +55,62 @@ def release_project_lock(self, **kwargs: Any) -> Response: return Response(204) +def test_constructor_accepts_an_access_token_only() -> None: + client = VolcanoClient(anon_key="anon", access_token="access-token") + + assert client.current_session == Session(access_token="access-token") + assert client.current_user is None + + +def test_constructor_accepts_an_access_and_refresh_token() -> None: + client = VolcanoClient( + anon_key="anon", + access_token="access-token", + refresh_token="refresh-token", + ) + + assert client.current_session == Session( + access_token="access-token", + refresh_token="refresh-token", + ) + assert client.current_user is None + + +def test_constructor_rejects_a_refresh_token_without_an_access_token() -> None: + with pytest.raises( + ValueError, + match="refresh_token requires access_token", + ): + VolcanoClient(anon_key="anon", refresh_token="refresh-token") + + +def test_auth_state_can_be_replaced_and_cleared() -> None: + client = VolcanoClient(anon_key="anon") + session = Session( + access_token="access-token", + refresh_token="refresh-token", + expires_in=3600, + user_id="user-123", + ) + user = User(id="user-123", email="user@example.com") + + client._commit_auth(session, user) + + assert client.current_session is session + assert client.current_user is user + + replacement = User(id="user-123", email="updated@example.com") + client._set_user(replacement) + + assert client.current_session is session + assert client.current_user is replacement + + client._clear_auth() + + assert client.current_session is None + assert client.current_user is None + + def test_query_builder_chains_are_immutable() -> None: transport = StateTransport() client = VolcanoClient(anon_key="anon", _transport=transport) From 8c706f55f0ba36fcfbdd35661ae0513f17fd8986 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:59:15 -0400 Subject: [PATCH 02/37] feat(auth): expand Python authentication transport --- src/volcano_sdk/_transport.py | 585 ++++++++++++++++++++++++- src/volcano_sdk/client.py | 6 +- tests/unit/test_generated_transport.py | 280 ++++++++++++ 3 files changed, 866 insertions(+), 5 deletions(-) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index d46bfb50..cd456959 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -6,20 +6,69 @@ from dataclasses import dataclass from io import BytesIO from pathlib import PurePosixPath -from typing import TYPE_CHECKING, Any, Protocol, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast from uuid import UUID, uuid4 import httpx -from ._generated.api.authentication import auth_signin +from ._generated.api.authentication import ( + auth_cancel_email_change, + auth_confirm_email, + auth_confirm_email_change, + auth_convert_anonymous, + auth_delete_all_my_sessions, + auth_delete_my_session, + auth_forgot_password, + auth_get_my_sessions, + auth_get_user, + auth_logout, + auth_refresh, + auth_request_email_change, + auth_resend_confirmation, + auth_reset_password, + auth_signin, + auth_signup, + auth_signup_anonymous, + auth_update_user, +) from ._generated.api.database_queries import query_database_select from ._generated.api.locks import acquire_project_lock, release_project_lock +from ._generated.api.o_auth_authentication import ( + auth_link_o_auth_provider, + auth_list_o_auth_providers, + auth_o_auth_authorize, + auth_o_auth_exchange, + auth_unlink_o_auth_provider, + call_o_auth_provider_api, + get_o_auth_provider_token, + refresh_o_auth_provider_token, +) from ._generated.api.storage_objects import ( download_storage_object, upload_storage_object, ) from ._generated.client import AuthenticatedClient +from ._generated.models.auth_confirm_email_body import AuthConfirmEmailBody +from ._generated.models.auth_confirm_email_change_body import ( + AuthConfirmEmailChangeBody, +) +from ._generated.models.auth_convert_anonymous_body import AuthConvertAnonymousBody +from ._generated.models.auth_forgot_password_body import AuthForgotPasswordBody +from ._generated.models.auth_logout_body import AuthLogoutBody +from ._generated.models.auth_o_auth_exchange_body import AuthOAuthExchangeBody +from ._generated.models.auth_refresh_body import AuthRefreshBody +from ._generated.models.auth_request_email_change_body import ( + AuthRequestEmailChangeBody, +) +from ._generated.models.auth_resend_confirmation_body import ( + AuthResendConfirmationBody, +) +from ._generated.models.auth_reset_password_body import AuthResetPasswordBody from ._generated.models.auth_signin_body import AuthSigninBody +from ._generated.models.auth_signup_anonymous_body import AuthSignupAnonymousBody +from ._generated.models.auth_signup_body import AuthSignupBody +from ._generated.models.auth_update_user_body import AuthUpdateUserBody +from ._generated.models.call_o_auth_provider_api_body import CallOAuthProviderAPIBody from ._generated.models.database_select_request import DatabaseSelectRequest from ._generated.models.project_lock_lease_request import ProjectLockLeaseRequest from ._generated.models.upload_storage_object_files_body import ( @@ -40,6 +89,8 @@ if TYPE_CHECKING: from collections.abc import Callable, Mapping + from .models import JSONValue, OAuthProviderName + HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_RATE_LIMITED = 429 @@ -79,6 +130,15 @@ class _GeneratedTransportResponse: class Transport(Protocol): + def auth_signup( + self, + *, + authorization: str, + email: str, + password: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: ... + def auth_signin( self, *, @@ -87,6 +147,179 @@ def auth_signin( password: str, ) -> TransportResponse: ... + def auth_refresh( + self, + *, + authorization: str, + refresh_token: str, + ) -> TransportResponse: ... + + def auth_logout( + self, + *, + authorization: str, + refresh_token: str, + ) -> TransportResponse: ... + + def auth_get_user(self, *, authorization: str) -> TransportResponse: ... + + def auth_update_user( + self, + *, + authorization: str, + password: str | None = None, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: ... + + def auth_signup_anonymous( + self, + *, + authorization: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: ... + + def auth_convert_anonymous( + self, + *, + authorization: str, + email: str, + password: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: ... + + def auth_confirm_email( + self, + *, + authorization: str, + token: str, + ) -> TransportResponse: ... + + def auth_resend_confirmation( + self, + *, + authorization: str, + email: str, + ) -> TransportResponse: ... + + def auth_forgot_password( + self, + *, + authorization: str, + email: str, + ) -> TransportResponse: ... + + def auth_reset_password( + self, + *, + authorization: str, + token: str, + new_password: str, + ) -> TransportResponse: ... + + def auth_request_email_change( + self, + *, + authorization: str, + new_email: str, + ) -> TransportResponse: ... + + def auth_confirm_email_change( + self, + *, + authorization: str, + email_change_token: str, + ) -> TransportResponse: ... + + def auth_cancel_email_change( + self, + *, + authorization: str, + ) -> TransportResponse: ... + + def auth_oauth_authorize( + self, + *, + authorization: str, + provider: OAuthProviderName, + redirect_url: str, + state: str, + ) -> TransportResponse: ... + + def auth_oauth_exchange( + self, + *, + authorization: str, + code: str, + redirect_url: str, + ) -> TransportResponse: ... + + def auth_link_oauth_provider( + self, + *, + authorization: str, + provider: OAuthProviderName, + redirect_url: str, + state: str, + ) -> TransportResponse: ... + + def auth_unlink_oauth_provider( + self, + *, + authorization: str, + provider: OAuthProviderName, + ) -> TransportResponse: ... + + def auth_list_oauth_providers( + self, + *, + authorization: str, + ) -> TransportResponse: ... + + def refresh_oauth_provider_token( + self, + *, + authorization: str, + provider: OAuthProviderName, + ) -> TransportResponse: ... + + def get_oauth_provider_token( + self, + *, + authorization: str, + provider: OAuthProviderName, + ) -> TransportResponse: ... + + def call_oauth_provider_api( + self, + *, + authorization: str, + provider: OAuthProviderName, + endpoint: str, + method: Literal["GET", "POST"] = "GET", + body: dict[str, JSONValue] | None = None, + ) -> TransportResponse: ... + + def auth_get_my_sessions( + self, + *, + authorization: str, + page: int = 1, + limit: int = 20, + ) -> TransportResponse: ... + + def auth_delete_my_session( + self, + *, + authorization: str, + session_id: str, + ) -> TransportResponse: ... + + def auth_delete_all_my_sessions( + self, + *, + authorization: str, + ) -> TransportResponse: ... + def query_database_select( self, *, @@ -243,6 +476,354 @@ def auth_signin( ) return self._response(response) + def auth_signup( + self, + *, + authorization: str, + email: str, + password: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: + body_data: dict[str, Any] = {"email": email, "password": password} + if user_metadata is not None: + body_data["user_metadata"] = user_metadata + with self._client(authorization) as client: + response = auth_signup.sync_detailed( + client=client, + body=AuthSignupBody.from_dict(body_data), + ) + return self._response(response) + + def auth_refresh( + self, + *, + authorization: str, + refresh_token: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_refresh.sync_detailed( + client=client, + body=AuthRefreshBody(refresh_token=refresh_token), + ) + 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 auth_get_user(self, *, authorization: str) -> TransportResponse: + with self._client(authorization) as client: + response = auth_get_user.sync_detailed(client=client) + return self._response(response) + + def auth_update_user( + self, + *, + authorization: str, + password: str | None = None, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: + body_data: dict[str, Any] = {} + if password is not None: + body_data["password"] = password + if user_metadata is not None: + body_data["user_metadata"] = user_metadata + with self._client(authorization) as client: + response = auth_update_user.sync_detailed( + client=client, + body=AuthUpdateUserBody.from_dict(body_data), + ) + return self._response(response) + + def auth_signup_anonymous( + self, + *, + authorization: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: + body_data = ( + {"user_metadata": user_metadata} if user_metadata is not None else {} + ) + with self._client(authorization) as client: + response = auth_signup_anonymous.sync_detailed( + client=client, + body=AuthSignupAnonymousBody.from_dict(body_data), + ) + return self._response(response) + + def auth_convert_anonymous( + self, + *, + authorization: str, + email: str, + password: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> TransportResponse: + body_data: dict[str, Any] = {"email": email, "password": password} + if user_metadata is not None: + body_data["user_metadata"] = user_metadata + with self._client(authorization) as client: + response = auth_convert_anonymous.sync_detailed( + client=client, + body=AuthConvertAnonymousBody.from_dict(body_data), + ) + return self._response(response) + + def auth_confirm_email( + self, + *, + authorization: str, + token: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_confirm_email.sync_detailed( + client=client, + body=AuthConfirmEmailBody(token=token), + ) + return self._response(response) + + def auth_resend_confirmation( + self, + *, + authorization: str, + email: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_resend_confirmation.sync_detailed( + client=client, + body=AuthResendConfirmationBody(email=email), + ) + return self._response(response) + + def auth_forgot_password( + self, + *, + authorization: str, + email: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_forgot_password.sync_detailed( + client=client, + body=AuthForgotPasswordBody(email=email), + ) + return self._response(response) + + def auth_reset_password( + self, + *, + authorization: str, + token: str, + new_password: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_reset_password.sync_detailed( + client=client, + body=AuthResetPasswordBody(token=token, new_password=new_password), + ) + return self._response(response) + + def auth_request_email_change( + self, + *, + authorization: str, + new_email: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_request_email_change.sync_detailed( + client=client, + body=AuthRequestEmailChangeBody(new_email=new_email), + ) + return self._response(response) + + def auth_confirm_email_change( + self, + *, + authorization: str, + email_change_token: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_confirm_email_change.sync_detailed( + client=client, + body=AuthConfirmEmailChangeBody( + email_change_token=email_change_token, + ), + ) + return self._response(response) + + def auth_cancel_email_change( + self, + *, + authorization: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_cancel_email_change.sync_detailed(client=client) + return self._response(response) + + def auth_oauth_authorize( + self, + *, + authorization: str, + provider: OAuthProviderName, + redirect_url: str, + state: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_o_auth_authorize.sync_detailed( + provider, + client=client, + anon_key=authorization, + redirect_url=redirect_url, + client_state=state, + response_mode="code", + ) + return self._response(response) + + def auth_oauth_exchange( + self, + *, + authorization: str, + code: str, + redirect_url: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_o_auth_exchange.sync_detailed( + client=client, + body=AuthOAuthExchangeBody(code=code, redirect_url=redirect_url), + ) + return self._response(response) + + def auth_link_oauth_provider( + self, + *, + authorization: str, + provider: OAuthProviderName, + redirect_url: str, + state: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_link_o_auth_provider.sync_detailed( + provider, + client=client, + redirect_url=redirect_url, + client_state=state, + response_mode="code", + ) + return self._response(response) + + def auth_unlink_oauth_provider( + self, + *, + authorization: str, + provider: OAuthProviderName, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_unlink_o_auth_provider.sync_detailed( + provider, + client=client, + ) + return self._response(response) + + def auth_list_oauth_providers( + self, + *, + authorization: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_list_o_auth_providers.sync_detailed(client=client) + return self._response(response) + + def refresh_oauth_provider_token( + self, + *, + authorization: str, + provider: OAuthProviderName, + ) -> TransportResponse: + with self._client(authorization) as client: + response = refresh_o_auth_provider_token.sync_detailed( + provider, + client=client, + ) + return self._response(response) + + def get_oauth_provider_token( + self, + *, + authorization: str, + provider: OAuthProviderName, + ) -> TransportResponse: + with self._client(authorization) as client: + response = get_o_auth_provider_token.sync_detailed( + provider, + client=client, + ) + return self._response(response) + + def call_oauth_provider_api( + self, + *, + authorization: str, + provider: OAuthProviderName, + endpoint: str, + method: Literal["GET", "POST"] = "GET", + body: dict[str, JSONValue] | None = None, + ) -> TransportResponse: + body_data: dict[str, Any] = {"endpoint": endpoint, "method": method} + if body is not None: + body_data["body"] = body + with self._client(authorization) as client: + response = call_o_auth_provider_api.sync_detailed( + provider, + client=client, + body=CallOAuthProviderAPIBody.from_dict(body_data), + ) + return self._response(response) + + def auth_get_my_sessions( + self, + *, + authorization: str, + page: int = 1, + limit: int = 20, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_get_my_sessions.sync_detailed( + client=client, + page=page, + limit=limit, + ) + return self._response(response) + + def auth_delete_my_session( + self, + *, + authorization: str, + session_id: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_delete_my_session.sync_detailed( + UUID(session_id), + client=client, + ) + return self._response(response) + + def auth_delete_all_my_sessions( + self, + *, + authorization: str, + ) -> TransportResponse: + with self._client(authorization) as client: + response = auth_delete_all_my_sessions.sync_detailed(client=client) + return self._response(response) + def query_database_select( self, *, diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 864c5c79..8eb889cc 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TypedDict, Unpack +from typing import TypedDict, Unpack, cast from ._transport import GeneratedTransport, Transport from .auth import Auth @@ -32,7 +32,7 @@ def __init__( api_url: str = "https://api.volcano.dev", service_key: str | None = None, timeout: float = 60.0, - _transport: Transport | None = None, + _transport: object | None = None, _realtime_client_factory: CentrifugeFactory | None = None, **auth_bootstrap: Unpack[_AuthBootstrap], ) -> None: @@ -51,7 +51,7 @@ def __init__( ) self._current_user: User | None = None self._transport: Transport = ( - _transport + cast("Transport", _transport) if _transport is not None else GeneratedTransport(api_url=self._api_url, timeout=timeout) ) diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 30e5e4b3..25ac7711 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -7,6 +7,22 @@ from volcano_sdk._transport import GeneratedTransport +def _recording_transport() -> tuple[GeneratedTransport, list[httpx.Request]]: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(418) + + return ( + GeneratedTransport( + api_url="https://api.test.volcano.dev", + httpx_transport=httpx.MockTransport(handle), + ), + requests, + ) + + def test_generated_transport_calls_the_six_openapi_operations() -> None: requests: list[httpx.Request] = [] @@ -146,3 +162,267 @@ def handle(request: httpx.Request) -> httpx.Response: assert requests[5].headers["x-volcano-lock-token"] == ( "00000000-0000-4000-8000-000000000001" ) + + +def test_generated_transport_calls_session_core_operations() -> None: + transport, requests = _recording_transport() + + transport.auth_signup( + authorization="anon-key", + email="user@example.com", + password="signup-password", + user_metadata={"display_name": "User"}, + ) + transport.auth_signin( + authorization="anon-key", + email="user@example.com", + password="signin-password", + ) + transport.auth_refresh( + authorization="anon-key", + refresh_token="refresh-token", + ) + transport.auth_logout( + authorization="anon-key", + refresh_token="refresh-token", + ) + transport.auth_get_user(authorization="access-token") + transport.auth_update_user( + authorization="access-token", + password="new-password", + user_metadata={"display_name": "Updated"}, + ) + + assert [(request.method, request.url.path) for request in requests] == [ + ("POST", "/auth/signup"), + ("POST", "/auth/signin"), + ("POST", "/auth/refresh"), + ("POST", "/auth/logout"), + ("GET", "/auth/user"), + ("PUT", "/auth/user"), + ] + assert [request.headers["authorization"] for request in requests] == [ + "Bearer anon-key", + "Bearer anon-key", + "Bearer anon-key", + "Bearer anon-key", + "Bearer access-token", + "Bearer access-token", + ] + bodies = [ + json.loads(request.content) if request.content else None for request in requests + ] + assert bodies == [ + { + "email": "user@example.com", + "password": "signup-password", + "user_metadata": {"display_name": "User"}, + }, + {"email": "user@example.com", "password": "signin-password"}, + {"refresh_token": "refresh-token"}, + {"refresh_token": "refresh-token"}, + None, + { + "password": "new-password", + "user_metadata": {"display_name": "Updated"}, + }, + ] + + +def test_generated_transport_calls_account_operations() -> None: + transport, requests = _recording_transport() + + transport.auth_signup_anonymous( + authorization="anon-key", + user_metadata={"display_name": "Guest"}, + ) + transport.auth_convert_anonymous( + authorization="access-token", + email="user@example.com", + password="secret", + user_metadata={"plan": "developer"}, + ) + transport.auth_confirm_email( + authorization="anon-key", + token="confirmation-token", + ) + transport.auth_resend_confirmation( + authorization="anon-key", + email="user@example.com", + ) + transport.auth_forgot_password( + authorization="anon-key", + email="user@example.com", + ) + transport.auth_reset_password( + authorization="anon-key", + token="recovery-token", + new_password="new-password", + ) + transport.auth_request_email_change( + authorization="access-token", + new_email="new@example.com", + ) + transport.auth_confirm_email_change( + authorization="access-token", + email_change_token="email-change-token", + ) + transport.auth_cancel_email_change(authorization="access-token") + + assert [(request.method, request.url.path) for request in requests] == [ + ("POST", "/auth/signup-anonymous"), + ("POST", "/auth/user/convert-anonymous"), + ("POST", "/auth/confirm"), + ("POST", "/auth/resend-confirmation"), + ("POST", "/auth/forgot-password"), + ("POST", "/auth/reset-password"), + ("POST", "/auth/user/change-email"), + ("POST", "/auth/user/confirm-email-change"), + ("DELETE", "/auth/user/cancel-email-change"), + ] + assert [request.headers["authorization"] for request in requests] == [ + "Bearer anon-key", + "Bearer access-token", + "Bearer anon-key", + "Bearer anon-key", + "Bearer anon-key", + "Bearer anon-key", + "Bearer access-token", + "Bearer access-token", + "Bearer access-token", + ] + bodies = [ + json.loads(request.content) if request.content else None for request in requests + ] + assert bodies == [ + {"user_metadata": {"display_name": "Guest"}}, + { + "email": "user@example.com", + "password": "secret", + "user_metadata": {"plan": "developer"}, + }, + {"token": "confirmation-token"}, + {"email": "user@example.com"}, + {"email": "user@example.com"}, + {"token": "recovery-token", "new_password": "new-password"}, + {"new_email": "new@example.com"}, + {"email_change_token": "email-change-token"}, + None, + ] + + +def test_generated_transport_calls_oauth_operations() -> None: + transport, requests = _recording_transport() + + transport.auth_oauth_authorize( + authorization="anon-key", + provider="github", + redirect_url="https://app.example/callback", + state="oauth-state", + ) + transport.auth_oauth_exchange( + authorization="anon-key", + code="oauth-code", + redirect_url="https://app.example/callback", + ) + transport.auth_link_oauth_provider( + authorization="access-token", + provider="google", + redirect_url="https://app.example/link-callback", + state="link-state", + ) + transport.auth_unlink_oauth_provider( + authorization="access-token", + provider="google", + ) + transport.auth_list_oauth_providers(authorization="access-token") + transport.refresh_oauth_provider_token( + authorization="access-token", + provider="github", + ) + transport.get_oauth_provider_token( + authorization="access-token", + provider="github", + ) + transport.call_oauth_provider_api( + authorization="access-token", + provider="github", + endpoint="/user/repos", + method="POST", + body={"visibility": "private"}, + ) + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/auth/oauth/github/authorize"), + ("POST", "/auth/oauth/exchange"), + ("POST", "/auth/oauth/google/link"), + ("DELETE", "/auth/oauth/google/unlink"), + ("GET", "/auth/oauth/providers"), + ("POST", "/auth/oauth/github/refresh-token"), + ("GET", "/auth/oauth/github/token"), + ("POST", "/auth/oauth/github/call-api"), + ] + assert [request.headers["authorization"] for request in requests] == [ + "Bearer anon-key", + "Bearer anon-key", + "Bearer access-token", + "Bearer access-token", + "Bearer access-token", + "Bearer access-token", + "Bearer access-token", + "Bearer access-token", + ] + assert dict(requests[0].url.params) == { + "anon_key": "anon-key", + "redirect_url": "https://app.example/callback", + "client_state": "oauth-state", + "response_mode": "code", + } + assert json.loads(requests[1].content) == { + "code": "oauth-code", + "redirect_url": "https://app.example/callback", + } + assert dict(requests[2].url.params) == { + "redirect_url": "https://app.example/link-callback", + "client_state": "link-state", + "response_mode": "code", + } + assert json.loads(requests[7].content) == { + "endpoint": "/user/repos", + "method": "POST", + "body": {"visibility": "private"}, + } + + +def test_generated_transport_calls_device_session_operations() -> None: + transport, requests = _recording_transport() + + transport.auth_get_my_sessions( + authorization="access-token", + page=2, + limit=10, + ) + transport.auth_delete_my_session( + authorization="access-token", + session_id="00000000-0000-4000-8000-000000000040", + ) + transport.auth_delete_all_my_sessions(authorization="access-token") + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/auth/user/sessions"), + ( + "DELETE", + "/auth/user/sessions/00000000-0000-4000-8000-000000000040", + ), + ("DELETE", "/auth/user/sessions"), + ] + assert dict(requests[0].url.params) == { + "page": "2", + "limit": "10", + "sort": "last_activity", + } + assert [request.headers["authorization"] for request in requests] == [ + "Bearer access-token", + "Bearer access-token", + "Bearer access-token", + ] From 36f924ee45ab4f6904cfe4cbf092bfc2e1a25616 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:08:26 -0400 Subject: [PATCH 03/37] feat(auth): implement Python session lifecycle --- src/volcano_sdk/auth.py | 274 ++++++++++++++++++++-- src/volcano_sdk/client.py | 44 +++- tests/unit/test_auth.py | 440 +++++++++++++++++++++++++++++++++++- tests/unit/test_facade.py | 5 +- tests/unit/test_realtime.py | 5 +- tests/unit/test_state.py | 5 +- 6 files changed, 755 insertions(+), 18 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index c4dcaa7e..b23e8998 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -2,10 +2,22 @@ from __future__ import annotations -from typing import Protocol +from collections.abc import Mapping +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol, cast -from ._transport import Transport, invoke, response_payload -from .models import Session +from ._transport import Transport, TransportResponse, invoke, response_payload +from .errors import AuthenticationError +from .models import Session, SignUpResult, User + +if TYPE_CHECKING: + from collections.abc import Callable + + from .models import JSONValue + +_INVALID_AUTH_RESPONSE = "Authentication response is missing required fields" +_MISSING_AUTH_STATE = "No refresh token available" +_HTTP_UNAUTHORIZED = 401 class AuthContext(Protocol): @@ -13,31 +25,267 @@ class AuthContext(Protocol): _transport: Transport + @property + def current_session(self) -> Session | None: + """Return the client-owned session.""" + ... + + @property + def current_user(self) -> User | None: + """Return the client-owned user.""" + ... + def _anon_token(self) -> str: ... - def _set_session(self, session: Session) -> None: ... + def _session_token(self) -> str: ... + + def _commit_auth(self, session: Session, user: User) -> None: ... + + def _set_user(self, user: User) -> None: ... + + def _clear_auth(self) -> None: ... + + def _subscribe_auth( + self, + listener: Callable[[User | None], None], + ) -> Callable[[], None]: ... + + +def _mapping(value: object) -> Mapping[str, Any]: + if isinstance(value, Mapping): + return cast("Mapping[str, Any]", value) + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + + +def _optional_text(value: object) -> str | None: + return value if isinstance(value, str) else None + + +def _optional_bool(value: object) -> bool | None: + return value if isinstance(value, bool) else None + + +def _optional_int(value: object) -> int | None: + return value if isinstance(value, int) else None + + +def _optional_datetime(value: object) -> datetime | None: + if not isinstance(value, str): + return None + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def _optional_metadata(value: object) -> dict[str, JSONValue] | None: + if not isinstance(value, dict): + return None + metadata = cast("dict[object, object]", value) + if not all(isinstance(key, str) for key in metadata): + return None + return cast("dict[str, JSONValue]", metadata.copy()) + + +def _user(payload: Mapping[str, Any]) -> User: + user_id = payload.get("id") + if not isinstance(user_id, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + email_value = payload.get("email") + if not isinstance(email_value, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return User( + id=user_id, + email=email_value, + project_id=_optional_text(payload.get("project_id")), + email_confirmed=_optional_bool(payload.get("email_confirmed")), + user_metadata=_optional_metadata(payload.get("user_metadata")), + app_metadata=_optional_metadata(payload.get("app_metadata")), + avatar_url=_optional_text(payload.get("avatar_url")), + status=_optional_text(payload.get("status")), + banned_until=_optional_datetime(payload.get("banned_until")), + last_sign_in_at=_optional_datetime(payload.get("last_sign_in_at")), + created_at=_optional_datetime(payload.get("created_at")), + updated_at=_optional_datetime(payload.get("updated_at")), + ) + + +def _session_and_user(payload: Mapping[str, Any]) -> tuple[Session, User]: + access_token = payload.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + refresh_token_value = payload.get("refresh_token") + refresh_token = ( + refresh_token_value if isinstance(refresh_token_value, str) else None + ) + user = _user(_mapping(payload.get("user"))) + return ( + Session( + access_token=access_token, + refresh_token=refresh_token, + expires_in=_optional_int(payload.get("expires_in")), + user_id=user.id, + ), + user, + ) class Auth: - """Authenticate users and update the client session.""" + """Authenticate users and update client-owned auth state.""" def __init__(self, client: AuthContext) -> None: """Create an authentication facade backed by a client.""" self._client = client + def sign_up( + self, + *, + email: str, + password: str, + user_metadata: dict[str, JSONValue] | None = None, + sign_in: bool = False, + ) -> SignUpResult: + """Create an account and optionally sign in when policy permits.""" + response = invoke( + self._client._transport.auth_signup, + authorization=self._client._anon_token(), + email=email, + password=password, + user_metadata=user_metadata, + ) + payload = _mapping(response_payload(response, 201)) + confirmation_required = payload.get("confirmation_required") is True + message_value = payload.get("message") + message = message_value if isinstance(message_value, str) else "" + if sign_in and not confirmation_required: + session = self.sign_in(email=email, password=password) + return SignUpResult( + confirmation_required=False, + message=message, + user=self._client.current_user, + session=session, + ) + return SignUpResult( + confirmation_required=confirmation_required, + message=message, + ) + def sign_in(self, *, email: str, password: str) -> Session: - """Sign in a user and store the returned session.""" + """Sign in a user and replace client-owned auth state.""" response = invoke( self._client._transport.auth_signin, authorization=self._client._anon_token(), email=email, password=password, ) - payload = response_payload(response, 200) - session = Session( - access_token=payload["access_token"], - refresh_token=payload["refresh_token"], - user_id=payload["user"]["id"], - ) - self._client._set_session(session) + session, user = _session_and_user(_mapping(response_payload(response, 200))) + self._client._commit_auth(session, user) return session + + def sign_out(self) -> None: + """Revoke the refresh token and always clear local auth state.""" + session = self._client.current_session + try: + if session is not None and session.refresh_token is not None: + response = invoke( + self._client._transport.auth_logout, + authorization=self._client._anon_token(), + refresh_token=session.refresh_token, + ) + response_payload(response, 204) + finally: + self._client._clear_auth() + + def get_user(self) -> User: + """Load the current user from the API.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_get_user, + expected_status=200, + ) + ) + user = _user(_mapping(payload.get("user"))) + self._client._set_user(user) + return user + + def update_user( + self, + *, + password: str | None = None, + user_metadata: dict[str, JSONValue] | None = None, + ) -> User: + """Update the current user's password or metadata.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_update_user, + expected_status=200, + password=password, + user_metadata=user_metadata, + ) + ) + user = _user(_mapping(payload.get("user"))) + self._client._set_user(user) + return user + + def refresh_session(self) -> Session: + """Rotate the current refresh token and replace local auth state.""" + session = self._client.current_session + if session is None or session.refresh_token is None: + self._client._clear_auth() + raise AuthenticationError(_MISSING_AUTH_STATE) + + succeeded = False + try: + response = invoke( + self._client._transport.auth_refresh, + authorization=self._client._anon_token(), + refresh_token=session.refresh_token, + ) + refreshed, user = _session_and_user( + _mapping(response_payload(response, 200)) + ) + if refreshed.refresh_token is None: + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + self._client._commit_auth(refreshed, user) + succeeded = True + return refreshed + finally: + if not succeeded: + self._client._clear_auth() + + def on_auth_state_change( + self, + listener: Callable[[User | None], None], + ) -> Callable[[], None]: + """Observe committed auth state and return an idempotent unsubscribe.""" + return self._client._subscribe_auth(listener) + + def _authenticated_payload( + self, + operation: Callable[..., TransportResponse], + *, + expected_status: int, + **kwargs: object, + ) -> object: + try: + response = invoke( + operation, + authorization=self._client._session_token(), + **kwargs, + ) + return response_payload(response, expected_status) + except AuthenticationError as error: + session = self._client.current_session + if ( + error.status != _HTTP_UNAUTHORIZED + or session is None + or session.refresh_token is None + ): + raise + self.refresh_session() + response = invoke( + operation, + authorization=self._client._session_token(), + **kwargs, + ) + return response_payload(response, expected_status) diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 8eb889cc..9c4efec9 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -2,7 +2,9 @@ from __future__ import annotations -from typing import TypedDict, Unpack, cast +import logging +from contextlib import suppress +from typing import TYPE_CHECKING, TypedDict, Unpack, cast from ._transport import GeneratedTransport, Transport from .auth import Auth @@ -12,9 +14,15 @@ from .realtime import CentrifugeFactory, Realtime from .storage import Storage +if TYPE_CHECKING: + from collections.abc import Callable + _NO_ACTIVE_SESSION = "No active session" _NO_SERVICE_KEY = "No service key configured" _REFRESH_WITHOUT_ACCESS = "refresh_token requires access_token" +_AUTH_LISTENER_FAILED = "Authentication state listener failed" + +_LOGGER = logging.getLogger(__name__) class _AuthBootstrap(TypedDict, total=False): @@ -50,6 +58,8 @@ def __init__( else None ) self._current_user: User | None = None + self._auth_listeners: dict[int, Callable[[User | None], None]] = {} + self._next_auth_listener_id = 0 self._transport: Transport = ( cast("Transport", _transport) if _transport is not None @@ -100,10 +110,42 @@ def _set_session(self, session: Session) -> None: def _commit_auth(self, session: Session, user: User) -> None: self._current_session = session self._current_user = user + self._notify_auth_listeners() def _set_user(self, user: User) -> None: self._current_user = user + self._notify_auth_listeners() def _clear_auth(self) -> None: self._current_session = None self._current_user = None + self._notify_auth_listeners() + + def _subscribe_auth( + self, + listener: Callable[[User | None], None], + ) -> Callable[[], None]: + listener_id = self._next_auth_listener_id + self._next_auth_listener_id += 1 + self._auth_listeners[listener_id] = listener + self._invoke_auth_listener(listener) + + def unsubscribe() -> None: + self._auth_listeners.pop(listener_id, None) + + return unsubscribe + + def _notify_auth_listeners(self) -> None: + for listener in tuple(self._auth_listeners.values()): + self._invoke_auth_listener(listener) + + def _invoke_auth_listener( + self, + listener: Callable[[User | None], None], + ) -> None: + completed = False + with suppress(Exception): + listener(self._current_user) + completed = True + if not completed: + _LOGGER.error(_AUTH_LISTENER_FAILED) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 9f722627..da4a9a6b 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,7 +1,8 @@ from __future__ import annotations -from dataclasses import FrozenInstanceError, fields +from dataclasses import FrozenInstanceError, dataclass, fields from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any import pytest @@ -17,7 +18,85 @@ SessionPage, SignUpResult, User, + VolcanoClient, ) +from volcano_sdk.errors import AuthenticationError, ServerError, ValidationError + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class AuthResponse: + status_code: int + payload: Any = None + content: bytes = b"" + headers: dict[str, str] | None = None + + +def _user_payload(*, email: str = "user@example.com") -> dict[str, Any]: + return { + "id": "user-123", + "email": email, + "project_id": "project-123", + "email_confirmed": True, + "user_metadata": {"display_name": "User"}, + "app_metadata": {"role": "developer"}, + "avatar_url": "https://example.com/avatar.png", + "status": "active", + "last_sign_in_at": "2026-08-28T12:00:00Z", + "created_at": "2026-08-27T12:00:00Z", + "updated_at": "2026-08-28T12:00:00Z", + } + + +def _token_payload( + *, + access_token: str, + refresh_token: str, + email: str = "user@example.com", +) -> dict[str, Any]: + return { + "access_token": access_token, + "refresh_token": refresh_token, + "expires_in": 3600, + "user": _user_payload(email=email), + } + + +class AuthTransport: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.responses: dict[str, list[AuthResponse]] = {} + + def queue(self, operation: str, *responses: AuthResponse) -> None: + self.responses.setdefault(operation, []).extend(responses) + + def _invoke(self, operation: str, kwargs: dict[str, Any]) -> AuthResponse: + self.calls.append((operation, kwargs)) + responses = self.responses.get(operation) + if not responses: + message = f"no response queued for {operation}" + raise AssertionError(message) + return responses.pop(0) + + def auth_signup(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_signup", kwargs) + + def auth_signin(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_signin", kwargs) + + def auth_refresh(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_refresh", kwargs) + + def auth_logout(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_logout", kwargs) + + def auth_get_user(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_get_user", kwargs) + + def auth_update_user(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_update_user", kwargs) def test_public_auth_values_are_frozen_and_slotted() -> None: @@ -124,3 +203,362 @@ def test_oauth_provider_name_accepts_the_supported_providers() -> None: ) assert providers == ("google", "github", "microsoft", "apple") + + +def test_sign_up_returns_a_sessionless_result() -> None: + transport = AuthTransport() + transport.queue( + "auth_signup", + AuthResponse( + 201, + { + "confirmation_required": True, + "message": "Check your email", + }, + ), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + result = client.auth.sign_up( + email="user@example.com", + password="secret", + user_metadata={"display_name": "User"}, + ) + + assert result == SignUpResult( + confirmation_required=True, + message="Check your email", + ) + assert client.current_session is None + assert client.current_user is None + assert transport.calls == [ + ( + "auth_signup", + { + "authorization": "anon-key", + "email": "user@example.com", + "password": "secret", + "user_metadata": {"display_name": "User"}, + }, + ) + ] + + +def test_sign_up_can_sign_in_immediately_when_confirmation_is_not_required() -> None: + transport = AuthTransport() + transport.queue( + "auth_signup", + AuthResponse( + 201, + { + "confirmation_required": False, + "message": "Account created", + }, + ), + ) + transport.queue( + "auth_signin", + AuthResponse( + 200, + _token_payload( + access_token="access-token", + refresh_token="refresh-token", + ), + ), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + result = client.auth.sign_up( + email="user@example.com", + password="secret", + sign_in=True, + ) + + assert result.user is client.current_user + assert result.session is client.current_session + assert result.confirmation_required is False + assert [operation for operation, _ in transport.calls] == [ + "auth_signup", + "auth_signin", + ] + + +def test_sign_up_raises_the_typed_api_error() -> None: + transport = AuthTransport() + transport.queue( + "auth_signup", + AuthResponse(422, {"error": "Password is too short"}), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + with pytest.raises(ValidationError, match="Password is too short"): + client.auth.sign_up(email="user@example.com", password="short") + + +def test_sign_in_commits_session_and_user_before_notifying_listeners() -> None: + transport = AuthTransport() + transport.queue( + "auth_signin", + AuthResponse( + 200, + _token_payload( + access_token="access-token", + refresh_token="refresh-token", + ), + ), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + observations: list[tuple[Session | None, User | None, User | None]] = [] + + client.auth.on_auth_state_change( + lambda user: observations.append( + (client.current_session, client.current_user, user) + ) + ) + + session = client.auth.sign_in(email="user@example.com", password="secret") + + assert session == Session( + access_token="access-token", + refresh_token="refresh-token", + expires_in=3600, + user_id="user-123", + ) + assert client.current_session is session + assert client.current_user == User( + id="user-123", + email="user@example.com", + project_id="project-123", + email_confirmed=True, + user_metadata={"display_name": "User"}, + app_metadata={"role": "developer"}, + avatar_url="https://example.com/avatar.png", + status="active", + last_sign_in_at=datetime(2026, 8, 28, 12, tzinfo=UTC), + created_at=datetime(2026, 8, 27, 12, tzinfo=UTC), + updated_at=datetime(2026, 8, 28, 12, tzinfo=UTC), + ) + assert observations[0] == (None, None, None) + assert observations[1] == ( + client.current_session, + client.current_user, + client.current_user, + ) + + +def test_get_and_update_user_preserve_the_current_session() -> None: + transport = AuthTransport() + transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) + transport.queue( + "auth_update_user", + AuthResponse( + 200, + {"user": _user_payload(email="updated@example.com")}, + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + session = client.current_session + + fetched = client.auth.get_user() + updated = client.auth.update_user( + password="new-password", + user_metadata={"display_name": "Updated"}, + ) + + assert fetched.email == "user@example.com" + assert updated.email == "updated@example.com" + assert client.current_user is updated + assert client.current_session is session + assert transport.calls == [ + ("auth_get_user", {"authorization": "access-token"}), + ( + "auth_update_user", + { + "authorization": "access-token", + "password": "new-password", + "user_metadata": {"display_name": "Updated"}, + }, + ), + ] + + +def test_refresh_rotates_tokens_and_replaces_user_state() -> None: + transport = AuthTransport() + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="access-token-2", + refresh_token="refresh-token-2", + email="updated@example.com", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token-1", + refresh_token="refresh-token-1", + _transport=transport, + ) + + session = client.auth.refresh_session() + + assert session.access_token == "access-token-2" + assert session.refresh_token == "refresh-token-2" + assert client.current_session is session + assert client.current_user is not None + assert client.current_user.email == "updated@example.com" + assert transport.calls == [ + ( + "auth_refresh", + { + "authorization": "anon-key", + "refresh_token": "refresh-token-1", + }, + ) + ] + + +def test_refresh_without_a_refresh_token_clears_local_auth() -> None: + client = VolcanoClient(anon_key="anon-key", access_token="access-token") + client._set_user(User(id="user-123", email="user@example.com")) + + with pytest.raises(AuthenticationError, match="No refresh token available"): + client.auth.refresh_session() + + assert client.current_session is None + assert client.current_user is None + + +def test_failed_refresh_clears_local_auth() -> None: + transport = AuthTransport() + transport.queue( + "auth_refresh", + AuthResponse(401, {"error": "Refresh token expired"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + client._set_user(User(id="user-123", email="user@example.com")) + + with pytest.raises(AuthenticationError, match="Refresh token expired"): + client.auth.refresh_session() + + assert client.current_session is None + assert client.current_user is None + + +def test_authenticated_request_refreshes_once_and_replays() -> None: + transport = AuthTransport() + transport.queue( + "auth_get_user", + AuthResponse(401, {"error": "Access token expired"}), + AuthResponse(200, {"user": _user_payload(email="fresh@example.com")}), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="access-token-2", + refresh_token="refresh-token-2", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token-1", + refresh_token="refresh-token-1", + _transport=transport, + ) + + user = client.auth.get_user() + + assert user.email == "fresh@example.com" + assert transport.calls == [ + ("auth_get_user", {"authorization": "access-token-1"}), + ( + "auth_refresh", + { + "authorization": "anon-key", + "refresh_token": "refresh-token-1", + }, + ), + ("auth_get_user", {"authorization": "access-token-2"}), + ] + + +def test_sign_out_always_clears_local_auth() -> None: + transport = AuthTransport() + transport.queue("auth_logout", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + client._set_user(User(id="user-123", email="user@example.com")) + + client.auth.sign_out() + + assert client.current_session is None + assert client.current_user is None + + transport.queue("auth_logout", AuthResponse(500, {"error": "Logout failed"})) + client._commit_auth( + Session(access_token="access-token", refresh_token="refresh-token"), + User(id="user-123", email="user@example.com"), + ) + + with pytest.raises(ServerError, match="Logout failed"): + client.auth.sign_out() + + assert client.current_session is None + assert client.current_user is None + + +def test_auth_state_listeners_are_immediate_isolated_and_idempotent( + caplog: pytest.LogCaptureFixture, +) -> None: + client = VolcanoClient(anon_key="anon-key") + observed: list[tuple[str, User | None]] = [] + second_unsubscribers: list[Callable[[], None]] = [] + + def first(user: User | None) -> None: + observed.append(("first", user)) + if user is not None and second_unsubscribers: + second_unsubscribers[0]() + + def failing(_user: User | None) -> None: + listener_error_message = "refresh-token-secret" + raise RuntimeError(listener_error_message) + + unsubscribe_first = client.auth.on_auth_state_change(first) + client.auth.on_auth_state_change(failing) + second_unsubscribers.append( + client.auth.on_auth_state_change(lambda user: observed.append(("second", user))) + ) + observed.clear() + caplog.clear() + user = User(id="user-123", email="user@example.com") + + client._set_user(user) + + assert observed == [("first", user), ("second", user)] + assert "Authentication state listener failed" in caplog.text + assert "refresh-token-secret" not in caplog.text + + unsubscribe_first() + unsubscribe_first() + client._clear_auth() + + assert observed == [("first", user), ("second", user)] diff --git a/tests/unit/test_facade.py b/tests/unit/test_facade.py index d11833d3..0e0f6430 100644 --- a/tests/unit/test_facade.py +++ b/tests/unit/test_facade.py @@ -26,7 +26,10 @@ def auth_signin(self, **kwargs: Any) -> FakeResponse: { "access_token": "access-token", "refresh_token": "refresh-token", - "user": {"id": "user-123"}, + "user": { + "id": "user-123", + "email": "user@example.com", + }, }, ) diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 9556ffed..805d9f35 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -40,7 +40,10 @@ def auth_signin( { "access_token": self.access_token, "refresh_token": "refresh-token", - "user": {"id": "user-123"}, + "user": { + "id": "user-123", + "email": "user@example.com", + }, }, ) diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index f8724c51..c48fa758 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -29,7 +29,10 @@ def auth_signin(self, **kwargs: Any) -> Response: { "access_token": self.next_access_token, "refresh_token": f"refresh-{self.next_access_token}", - "user": {"id": "user-123"}, + "user": { + "id": "user-123", + "email": "user@example.com", + }, }, ) From b0fb9d6bf67ff5085d1ab5e2985bf32be011b978 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:15:40 -0400 Subject: [PATCH 04/37] feat(auth): complete Python authentication facade --- src/volcano_sdk/auth.py | 416 +++++++++++++++++++++++++++++++++++++++- tests/unit/test_auth.py | 385 ++++++++++++++++++++++++++++++++++++- 2 files changed, 797 insertions(+), 4 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index b23e8998..fc0b1e39 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -4,11 +4,26 @@ from collections.abc import Mapping from datetime import datetime -from typing import TYPE_CHECKING, Any, Protocol, cast +from hmac import compare_digest +from secrets import token_urlsafe +from typing import TYPE_CHECKING, Any, Literal, Protocol, cast +from urllib.parse import quote, urlencode from ._transport import Transport, TransportResponse, invoke, response_payload -from .errors import AuthenticationError -from .models import Session, SignUpResult, User +from .errors import AuthenticationError, ValidationError +from .models import ( + AuthorizationRequest, + AuthSession, + EmailChangeResult, + MessageResult, + OAuthProvider, + OAuthProviderName, + OAuthTokenResult, + Session, + SessionPage, + SignUpResult, + User, +) if TYPE_CHECKING: from collections.abc import Callable @@ -18,12 +33,17 @@ _INVALID_AUTH_RESPONSE = "Authentication response is missing required fields" _MISSING_AUTH_STATE = "No refresh token available" _HTTP_UNAUTHORIZED = 401 +_INVALID_OAUTH_STATE = "OAuth state does not match" +_INVALID_OAUTH_PROVIDER = "Unsupported OAuth provider" +_MISSING_AUTHORIZATION_URL = "Authentication response is missing authorization URL" +_SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) class AuthContext(Protocol): """Client capabilities required by the authentication facade.""" _transport: Transport + _api_url: str @property def current_session(self) -> Session | None: @@ -87,6 +107,77 @@ def _optional_metadata(value: object) -> dict[str, JSONValue] | None: return cast("dict[str, JSONValue]", metadata.copy()) +def _json_value(value: object) -> JSONValue: + if value is None or isinstance(value, (str, int, float, bool)): + return value + if isinstance(value, list): + items = cast("list[object]", value) + return [_json_value(item) for item in items] + if isinstance(value, dict): + mapping = cast("dict[object, object]", value) + if not all(isinstance(key, str) for key in mapping): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return {cast("str", key): _json_value(item) for key, item in mapping.items()} + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + + +def _provider(value: str) -> OAuthProviderName: + if value not in _SUPPORTED_OAUTH_PROVIDERS: + raise ValidationError(_INVALID_OAUTH_PROVIDER) + return cast("OAuthProviderName", value) + + +def _message(payload: Mapping[str, Any]) -> MessageResult: + message = payload.get("message") + if not isinstance(message, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return MessageResult(message=message) + + +def _oauth_token(payload: Mapping[str, Any]) -> OAuthTokenResult: + provider_value = payload.get("provider") + if not isinstance(provider_value, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return OAuthTokenResult( + provider=_provider(provider_value), + expires_in=_optional_int(payload.get("expires_in")), + message=_optional_text(payload.get("message")), + ) + + +def _auth_session(payload: Mapping[str, Any]) -> AuthSession: + session_id = payload.get("id") + user_id = payload.get("user_id") + provider = payload.get("provider") + expires_at = _optional_datetime(payload.get("expires_at")) + is_active = payload.get("is_active") + is_current = payload.get("is_current") + if ( + not isinstance(session_id, str) + or not isinstance(user_id, str) + or not isinstance(provider, str) + or expires_at is None + or not isinstance(is_active, bool) + or not isinstance(is_current, bool) + ): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return AuthSession( + id=session_id, + user_id=user_id, + provider=provider, + expires_at=expires_at, + is_active=is_active, + is_current=is_current, + user_agent=_optional_text(payload.get("user_agent")), + ip_address=_optional_text(payload.get("ip_address")), + last_ip_address=_optional_text(payload.get("last_ip_address")), + last_activity_at=_optional_datetime(payload.get("last_activity_at")), + session_started_at=_optional_datetime(payload.get("session_started_at")), + created_at=_optional_datetime(payload.get("created_at")), + updated_at=_optional_datetime(payload.get("updated_at")), + ) + + def _user(payload: Mapping[str, Any]) -> User: user_id = payload.get("id") if not isinstance(user_id, str): @@ -260,6 +351,325 @@ def on_auth_state_change( """Observe committed auth state and return an idempotent unsubscribe.""" return self._client._subscribe_auth(listener) + def sign_up_anonymous( + self, + *, + user_metadata: dict[str, JSONValue] | None = None, + ) -> Session: + """Create an anonymous user and replace client-owned auth state.""" + response = invoke( + self._client._transport.auth_signup_anonymous, + authorization=self._client._anon_token(), + user_metadata=user_metadata, + ) + session, user = _session_and_user(_mapping(response_payload(response, 201))) + self._client._commit_auth(session, user) + return session + + def convert_anonymous( + self, + *, + email: str, + password: str, + user_metadata: dict[str, JSONValue] | None = None, + ) -> User: + """Convert the current anonymous user to an email account.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_convert_anonymous, + expected_status=200, + email=email, + password=password, + user_metadata=user_metadata, + ) + ) + user = _user(_mapping(payload.get("user"))) + self._client._set_user(user) + return user + + def confirm_email(self, *, token: str) -> MessageResult: + """Confirm an email address with its one-time token.""" + response = invoke( + self._client._transport.auth_confirm_email, + authorization=self._client._anon_token(), + token=token, + ) + return _message(_mapping(response_payload(response, 200))) + + def resend_confirmation(self, *, email: str) -> MessageResult: + """Request another email-confirmation message.""" + response = invoke( + self._client._transport.auth_resend_confirmation, + authorization=self._client._anon_token(), + email=email, + ) + return _message(_mapping(response_payload(response, 200))) + + def forgot_password(self, *, email: str) -> MessageResult: + """Request a password-reset message.""" + response = invoke( + self._client._transport.auth_forgot_password, + authorization=self._client._anon_token(), + email=email, + ) + return _message(_mapping(response_payload(response, 200))) + + def reset_password(self, *, token: str, new_password: str) -> MessageResult: + """Reset a password with its one-time recovery token.""" + response = invoke( + self._client._transport.auth_reset_password, + authorization=self._client._anon_token(), + token=token, + new_password=new_password, + ) + return _message(_mapping(response_payload(response, 200))) + + def request_email_change(self, *, new_email: str) -> EmailChangeResult: + """Request a change to the current user's email address.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_request_email_change, + expected_status=200, + new_email=new_email, + ) + ) + message = payload.get("message") + response_email = payload.get("new_email") + if not isinstance(message, str) or not isinstance(response_email, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return EmailChangeResult( + message=message, + new_email=response_email, + email_change_token=_optional_text(payload.get("email_change_token")), + ) + + def confirm_email_change(self, *, token: str) -> MessageResult: + """Confirm a pending email change.""" + payload = self._authenticated_payload( + self._client._transport.auth_confirm_email_change, + expected_status=200, + email_change_token=token, + ) + return _message(_mapping(payload)) + + def cancel_email_change(self) -> MessageResult: + """Cancel the current user's pending email change.""" + payload = self._authenticated_payload( + self._client._transport.auth_cancel_email_change, + expected_status=200, + ) + return _message(_mapping(payload)) + + def get_hosted_auth_url( + self, + *, + project_id: str, + action: Literal["login", "signup", "forgot-password"] | None = None, + ) -> AuthorizationRequest: + """Build a hosted-auth URL without navigating a browser.""" + state = token_urlsafe(32) + query = {"anon_key": self._client._anon_token()} + if action is not None: + query["action"] = action + query["state"] = state + url = ( + f"{self._client._api_url}/projects/{quote(project_id, safe='')}/auth/hosted" + f"?{urlencode(query)}" + ) + return AuthorizationRequest(authorization_url=url, state=state) + + def get_oauth_authorization_url( + self, + *, + provider: OAuthProviderName, + redirect_url: str, + ) -> AuthorizationRequest: + """Start an OAuth flow and return its provider authorization URL.""" + validated_provider = _provider(provider) + state = token_urlsafe(32) + response = invoke( + self._client._transport.auth_oauth_authorize, + authorization=self._client._anon_token(), + provider=validated_provider, + redirect_url=redirect_url, + state=state, + ) + response_payload(response, 302) + authorization_url = self._response_header(response, "Location") + if authorization_url is None: + raise AuthenticationError(_MISSING_AUTHORIZATION_URL) + return AuthorizationRequest( + authorization_url=authorization_url, + state=state, + ) + + def exchange_oauth_code( + self, + *, + code: str, + redirect_url: str, + state: str, + expected_state: str, + ) -> Session: + """Validate caller state and exchange an OAuth code for a session.""" + if not compare_digest(state, expected_state): + raise ValidationError(_INVALID_OAUTH_STATE) + response = invoke( + self._client._transport.auth_oauth_exchange, + authorization=self._client._anon_token(), + code=code, + redirect_url=redirect_url, + ) + session, user = _session_and_user(_mapping(response_payload(response, 200))) + self._client._commit_auth(session, user) + return session + + def link_oauth_provider( + self, + *, + provider: OAuthProviderName, + redirect_url: str, + ) -> AuthorizationRequest: + """Start a flow that links an OAuth provider to the current user.""" + validated_provider = _provider(provider) + state = token_urlsafe(32) + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_link_oauth_provider, + expected_status=200, + provider=validated_provider, + redirect_url=redirect_url, + state=state, + ) + ) + authorization_url = payload.get("authorization_url") + if not isinstance(authorization_url, str): + raise AuthenticationError(_MISSING_AUTHORIZATION_URL) + return AuthorizationRequest( + authorization_url=authorization_url, + state=state, + ) + + def unlink_oauth_provider(self, *, provider: OAuthProviderName) -> None: + """Unlink an OAuth provider from the current user.""" + self._authenticated_payload( + self._client._transport.auth_unlink_oauth_provider, + expected_status=204, + provider=_provider(provider), + ) + + def get_linked_oauth_providers(self) -> tuple[OAuthProvider, ...]: + """Return OAuth providers linked to the current user.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_list_oauth_providers, + expected_status=200, + ) + ) + providers_value = payload.get("providers") + if not isinstance(providers_value, list): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + providers = cast("list[object]", providers_value) + return tuple( + OAuthProvider( + provider=_provider(str(provider_payload.get("provider"))), + linked_at=_optional_datetime(provider_payload.get("linked_at")), + updated_at=_optional_datetime(provider_payload.get("updated_at")), + ) + for provider_payload in (_mapping(item) for item in providers) + ) + + def refresh_oauth_token( + self, + *, + provider: OAuthProviderName, + ) -> OAuthTokenResult: + """Refresh the stored access token for an OAuth provider.""" + payload = self._authenticated_payload( + self._client._transport.refresh_oauth_provider_token, + expected_status=200, + provider=_provider(provider), + ) + return _oauth_token(_mapping(payload)) + + def get_oauth_provider_token( + self, + *, + provider: OAuthProviderName, + ) -> OAuthTokenResult: + """Get metadata for the current OAuth provider token.""" + payload = self._authenticated_payload( + self._client._transport.get_oauth_provider_token, + expected_status=200, + provider=_provider(provider), + ) + return _oauth_token(_mapping(payload)) + + def call_oauth_api( + self, + *, + provider: OAuthProviderName, + endpoint: str, + method: Literal["GET", "POST"] = "GET", + body: dict[str, JSONValue] | None = None, + ) -> JSONValue: + """Call a provider API through Volcano's fixed-host proxy.""" + payload = self._authenticated_payload( + self._client._transport.call_oauth_provider_api, + expected_status=200, + provider=_provider(provider), + endpoint=endpoint, + method=method, + body=body, + ) + return _json_value(payload) + + def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: + """Return a page of the current user's device sessions.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_get_my_sessions, + expected_status=200, + page=page, + limit=limit, + ) + ) + sessions_value = payload.get("sessions", payload.get("data")) + if not isinstance(sessions_value, list): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + sessions = cast("list[object]", sessions_value) + return SessionPage( + sessions=tuple(_auth_session(_mapping(item)) for item in sessions), + total=_optional_int(payload.get("total")), + page=_optional_int(payload.get("page")), + limit=_optional_int(payload.get("limit")), + total_pages=_optional_int(payload.get("total_pages")), + ) + + def delete_session(self, *, session_id: str) -> None: + """Delete one device session.""" + self._authenticated_payload( + self._client._transport.auth_delete_my_session, + expected_status=204, + session_id=session_id, + ) + + def delete_all_other_sessions(self) -> None: + """Delete every device session except the current one.""" + self._authenticated_payload( + self._client._transport.auth_delete_all_my_sessions, + expected_status=204, + ) + + @staticmethod + def _response_header(response: TransportResponse, name: str) -> str | None: + if response.headers is None: + return None + for key, value in response.headers.items(): + if key.lower() == name.lower(): + return value + return None + def _authenticated_payload( self, operation: Callable[..., TransportResponse], diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index da4a9a6b..73201c43 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -2,7 +2,7 @@ from dataclasses import FrozenInstanceError, dataclass, fields from datetime import UTC, datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import pytest @@ -98,6 +98,66 @@ def auth_get_user(self, **kwargs: Any) -> AuthResponse: def auth_update_user(self, **kwargs: Any) -> AuthResponse: return self._invoke("auth_update_user", kwargs) + def auth_signup_anonymous(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_signup_anonymous", kwargs) + + def auth_convert_anonymous(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_convert_anonymous", kwargs) + + def auth_confirm_email(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_confirm_email", kwargs) + + def auth_resend_confirmation(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_resend_confirmation", kwargs) + + def auth_forgot_password(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_forgot_password", kwargs) + + def auth_reset_password(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_reset_password", kwargs) + + def auth_request_email_change(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_request_email_change", kwargs) + + def auth_confirm_email_change(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_confirm_email_change", kwargs) + + def auth_cancel_email_change(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_cancel_email_change", kwargs) + + def auth_oauth_authorize(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_oauth_authorize", kwargs) + + def auth_oauth_exchange(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_oauth_exchange", kwargs) + + def auth_link_oauth_provider(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_link_oauth_provider", kwargs) + + def auth_unlink_oauth_provider(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_unlink_oauth_provider", kwargs) + + def auth_list_oauth_providers(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_list_oauth_providers", kwargs) + + def refresh_oauth_provider_token(self, **kwargs: Any) -> AuthResponse: + return self._invoke("refresh_oauth_provider_token", kwargs) + + def get_oauth_provider_token(self, **kwargs: Any) -> AuthResponse: + return self._invoke("get_oauth_provider_token", kwargs) + + def call_oauth_provider_api(self, **kwargs: Any) -> AuthResponse: + return self._invoke("call_oauth_provider_api", kwargs) + + def auth_get_my_sessions(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_get_my_sessions", kwargs) + + def auth_delete_my_session(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_delete_my_session", kwargs) + + def auth_delete_all_my_sessions(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_delete_all_my_sessions", kwargs) + def test_public_auth_values_are_frozen_and_slotted() -> None: user = User(id="user-123", email="user@example.com") @@ -562,3 +622,326 @@ def failing(_user: User | None) -> None: client._clear_auth() assert observed == [("first", user), ("second", user)] + + +def test_anonymous_and_email_account_flows_return_public_values() -> None: + transport = AuthTransport() + transport.queue( + "auth_signup_anonymous", + AuthResponse( + 201, + _token_payload( + access_token="anonymous-access", + refresh_token="anonymous-refresh", + ), + ), + ) + transport.queue( + "auth_convert_anonymous", + AuthResponse(200, {"user": _user_payload()}), + ) + for operation in ( + "auth_confirm_email", + "auth_resend_confirmation", + "auth_forgot_password", + "auth_reset_password", + "auth_confirm_email_change", + "auth_cancel_email_change", + ): + transport.queue(operation, AuthResponse(200, {"message": "Done"})) + transport.queue( + "auth_request_email_change", + AuthResponse( + 200, + { + "message": "Check your email", + "new_email": "new@example.com", + "email_change_token": "development-token", + }, + ), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + anonymous = client.auth.sign_up_anonymous(user_metadata={"display_name": "Guest"}) + converted = client.auth.convert_anonymous( + email="user@example.com", + password="secret", + user_metadata={"plan": "developer"}, + ) + messages = ( + client.auth.confirm_email(token="confirmation-token"), + client.auth.resend_confirmation(email="user@example.com"), + client.auth.forgot_password(email="user@example.com"), + client.auth.reset_password( + token="recovery-token", + new_password="new-password", + ), + ) + email_change = client.auth.request_email_change(new_email="new@example.com") + confirm_change = client.auth.confirm_email_change(token="email-change-token") + cancel_change = client.auth.cancel_email_change() + + assert anonymous is not None + assert anonymous.access_token == "anonymous-access" + assert converted is client.current_user + assert all(result == MessageResult(message="Done") for result in messages) + assert email_change == EmailChangeResult( + message="Check your email", + new_email="new@example.com", + email_change_token="development-token", + ) + assert "development-token" not in repr(email_change) + assert confirm_change == MessageResult(message="Done") + assert cancel_change == MessageResult(message="Done") + assert transport.calls[:2] == [ + ( + "auth_signup_anonymous", + { + "authorization": "anon-key", + "user_metadata": {"display_name": "Guest"}, + }, + ), + ( + "auth_convert_anonymous", + { + "authorization": "anonymous-access", + "email": "user@example.com", + "password": "secret", + "user_metadata": {"plan": "developer"}, + }, + ), + ] + + +def test_hosted_and_oauth_authorization_urls_bind_caller_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def fixed_state(_size: int) -> str: + return "fixed-state" + + monkeypatch.setattr("volcano_sdk.auth.token_urlsafe", fixed_state) + transport = AuthTransport() + transport.queue( + "auth_oauth_authorize", + AuthResponse( + 302, + headers={"Location": "https://github.com/login/oauth/authorize"}, + ), + ) + transport.queue( + "auth_link_oauth_provider", + AuthResponse( + 200, {"authorization_url": "https://accounts.google.com/o/oauth2"} + ), + ) + client = VolcanoClient( + api_url="https://api.test.volcano.dev", + anon_key="anon key", + access_token="access-token", + _transport=transport, + ) + + hosted = client.auth.get_hosted_auth_url( + project_id="project/id", + action="signup", + ) + oauth = client.auth.get_oauth_authorization_url( + provider="github", + redirect_url="https://app.example/callback", + ) + linked = client.auth.link_oauth_provider( + provider="google", + redirect_url="https://app.example/link-callback", + ) + + assert hosted == AuthorizationRequest( + authorization_url=( + "https://api.test.volcano.dev/projects/project%2Fid/auth/hosted" + "?anon_key=anon+key&action=signup&state=fixed-state" + ), + state="fixed-state", + ) + assert oauth == AuthorizationRequest( + authorization_url="https://github.com/login/oauth/authorize", + state="fixed-state", + ) + assert linked == AuthorizationRequest( + authorization_url="https://accounts.google.com/o/oauth2", + state="fixed-state", + ) + assert transport.calls == [ + ( + "auth_oauth_authorize", + { + "authorization": "anon key", + "provider": "github", + "redirect_url": "https://app.example/callback", + "state": "fixed-state", + }, + ), + ( + "auth_link_oauth_provider", + { + "authorization": "access-token", + "provider": "google", + "redirect_url": "https://app.example/link-callback", + "state": "fixed-state", + }, + ), + ] + + +def test_oauth_exchange_validates_state_before_committing() -> None: + transport = AuthTransport() + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + with pytest.raises(ValidationError, match="OAuth state does not match"): + client.auth.exchange_oauth_code( + code="oauth-code", + redirect_url="https://app.example/callback", + state="callback-state", + expected_state="stored-state", + ) + + with pytest.raises(ValidationError, match="Unsupported OAuth provider"): + client.auth.get_oauth_authorization_url( + provider=cast("OAuthProviderName", "twitter"), + redirect_url="https://app.example/callback", + ) + + assert transport.calls == [] + + transport.queue( + "auth_oauth_exchange", + AuthResponse( + 200, + _token_payload( + access_token="oauth-access", + refresh_token="oauth-refresh", + ), + ), + ) + + session = client.auth.exchange_oauth_code( + code="oauth-code", + redirect_url="https://app.example/callback", + state="stored-state", + expected_state="stored-state", + ) + + assert session is client.current_session + assert client.current_user is not None + assert transport.calls == [ + ( + "auth_oauth_exchange", + { + "authorization": "anon-key", + "code": "oauth-code", + "redirect_url": "https://app.example/callback", + }, + ) + ] + + +def test_provider_and_device_session_flows_return_public_values() -> None: + transport = AuthTransport() + transport.queue("auth_unlink_oauth_provider", AuthResponse(204)) + transport.queue( + "auth_list_oauth_providers", + AuthResponse( + 200, + { + "providers": [ + { + "provider": "github", + "linked_at": "2026-08-27T12:00:00Z", + "updated_at": "2026-08-28T12:00:00Z", + } + ] + }, + ), + ) + token_payload = { + "provider": "github", + "expires_in": 3600, + "message": "Ready", + } + transport.queue("refresh_oauth_provider_token", AuthResponse(200, token_payload)) + transport.queue("get_oauth_provider_token", AuthResponse(200, token_payload)) + transport.queue( + "call_oauth_provider_api", + AuthResponse(200, {"login": "octocat", "private": False}), + ) + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "sessions": [ + { + "id": "session-123", + "user_id": "user-123", + "provider": "email", + "expires_at": "2026-08-29T12:00:00Z", + "is_active": True, + "is_current": True, + } + ], + "total": 1, + "page": 1, + "limit": 20, + "total_pages": 1, + }, + ), + ) + transport.queue("auth_delete_my_session", AuthResponse(204)) + transport.queue("auth_delete_all_my_sessions", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + + client.auth.unlink_oauth_provider(provider="github") + providers = client.auth.get_linked_oauth_providers() + refreshed = client.auth.refresh_oauth_token(provider="github") + current = client.auth.get_oauth_provider_token(provider="github") + provider_data = client.auth.call_oauth_api( + provider="github", + endpoint="/user", + ) + sessions = client.auth.get_sessions(page=1, limit=20) + client.auth.delete_session(session_id="session-123") + client.auth.delete_all_other_sessions() + + assert providers == ( + OAuthProvider( + provider="github", + linked_at=datetime(2026, 8, 27, 12, tzinfo=UTC), + updated_at=datetime(2026, 8, 28, 12, tzinfo=UTC), + ), + ) + assert refreshed == OAuthTokenResult( + provider="github", + expires_in=3600, + message="Ready", + ) + assert current == refreshed + assert provider_data == {"login": "octocat", "private": False} + assert sessions == SessionPage( + sessions=( + AuthSession( + id="session-123", + user_id="user-123", + provider="email", + expires_at=datetime(2026, 8, 29, 12, tzinfo=UTC), + is_active=True, + is_current=True, + ), + ), + total=1, + page=1, + limit=20, + total_pages=1, + ) From 8961e079caa681029c6d6868a47d05f430efb682 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:20:55 -0400 Subject: [PATCH 05/37] test(auth): bind Python authentication contract --- README.md | 3 + docs/authentication.md | 174 ++++++++++++++++++ features/contract/auth.feature | 62 +++++++ features/contract_support.py | 11 ++ features/steps/sdk_contract_steps.py | 254 ++++++++++++++++++++++++++- tests/unit/test_contract_bindings.py | 32 +++- 6 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 docs/authentication.md diff --git a/README.md b/README.md index 3fc86884..be06d266 100644 --- a/README.md +++ b/README.md @@ -77,3 +77,6 @@ VOLCANO_SDK_CONTRACT_FIXTURE=/absolute/path/to/fixture.json \ ``` The fixture must be an absolute path to a mode-`0600` JSON file. + +See [Authentication](docs/authentication.md) for account, session, hosted auth, +and OAuth examples. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 00000000..3ab44dcb --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,174 @@ +# Authentication + +Create a client with your project URL and anonymous key, then use `client.auth` +for account and session operations. + +```python +from volcano_sdk import VolcanoClient + +client = VolcanoClient( + api_url="https://api.volcano.dev", + anon_key="your-anon-key", +) + +session = client.auth.sign_in( + email="user@example.com", + password="your-password", +) + +print(client.current_user.id) +print(session.expires_at) +``` + +The client keeps the active `current_user` and `current_session` in memory. +Pass both tokens when restoring an existing session in a new client. An access +token without a refresh token is valid, but a refresh token without an access +token is rejected. + +```python +restored = VolcanoClient( + api_url="https://api.volcano.dev", + anon_key="your-anon-key", + access_token=session.access_token, + refresh_token=session.refresh_token, +) + +restored.auth.refresh_session() +``` + +Store tokens in the secure storage provided by your runtime. Do not log them or +place them in source control. + +## Manage the session + +Subscribe to auth-state changes when application state must follow the client. +The listener runs immediately with the current user and after committed auth +changes. Call the returned function to unsubscribe. + +```python +unsubscribe = client.auth.on_auth_state_change( + lambda user: print("signed in" if user else "signed out") +) + +client.auth.refresh_session() +client.auth.sign_out() +unsubscribe() +``` + +A failed refresh clears local authentication so stale credentials are not +reused. `sign_out()` also clears local state if the remote revoke fails. + +List and revoke device sessions through the same facade: + +```python +page = client.auth.get_sessions(page=1, limit=20) + +for device_session in page.sessions: + print(device_session.id, device_session.last_seen_at) + +client.auth.delete_session(session_id="session-id") +client.auth.delete_all_other_sessions() +``` + +## Create and update accounts + +Sign-up can return without a session when email confirmation is required. + +```python +result = client.auth.sign_up( + email="new-user@example.com", + password="your-password", + user_metadata={"plan": "starter"}, +) + +if result.confirmation_required: + print("Check your email") +``` + +Update the current user, or start with an anonymous account and preserve its +identity when converting it: + +```python +client.auth.update_user(user_metadata={"plan": "pro"}) + +client.auth.sign_out() +client.auth.sign_up_anonymous(user_metadata={"source": "demo"}) +anonymous_id = client.current_user.id + +converted = client.auth.convert_anonymous( + email="converted@example.com", + password="your-password", +) +assert converted.id == anonymous_id +``` + +Email workflows are available as explicit operations: + +```python +client.auth.resend_confirmation(email="new-user@example.com") +client.auth.confirm_email(token="confirmation-token") +client.auth.forgot_password(email="user@example.com") +client.auth.reset_password( + token="recovery-token", + new_password="your-new-password", +) + +change = client.auth.request_email_change(new_email="next@example.com") +client.auth.confirm_email_change(token="email-change-token") +# Or cancel a pending request: +client.auth.cancel_email_change() +``` + +## Open hosted auth and OAuth + +Hosted auth returns a URL and generated state value for your application to +retain before navigation: + +```python +request = client.auth.get_hosted_auth_url( + project_id="project-id", + action="login", +) +print(request.authorization_url) +``` + +OAuth authorization follows the same pattern. Preserve `request.state` and +pass it as `expected_state` during exchange; the SDK rejects a mismatch before +calling the API. + +```python +request = client.auth.get_oauth_authorization_url( + provider="github", + redirect_url="https://app.example.com/auth/callback", +) + +# After the provider redirects to your application: +client.auth.exchange_oauth_code( + code="authorization-code", + redirect_url="https://app.example.com/auth/callback", + state="state-from-callback", + expected_state=request.state, +) +``` + +Signed-in users can link providers, inspect them, refresh provider tokens, and +call provider APIs through Volcano: + +```python +link = client.auth.link_oauth_provider( + provider="github", + redirect_url="https://app.example.com/auth/link/callback", +) + +providers = client.auth.get_linked_oauth_providers() +token = client.auth.get_oauth_provider_token(provider="github") +client.auth.refresh_oauth_token(provider="github") +profile = client.auth.call_oauth_api( + provider="github", + endpoint="/user", +) +client.auth.unlink_oauth_provider(provider="github") +``` + +Keep generated state values and provider tokens secret. Navigate to the returned +authorization URL only after storing its matching state value. diff --git a/features/contract/auth.feature b/features/contract/auth.feature index c24af106..6296153d 100644 --- a/features/contract/auth.feature +++ b/features/contract/auth.feature @@ -7,3 +7,65 @@ Feature: SDK authentication contract Then the SDK operation succeeds And the current session belongs to the contract user And the current session exposes access and refresh tokens + + @auth @SDK-AUTH-002 + Scenario: Password sign-up acknowledges a session-less account + Given a unique unconfirmed contract user + When the client signs up with the new user's credentials + Then the SDK operation succeeds + And sign-up is acknowledged without a session + And the current session is empty + + @auth @SDK-AUTH-003 + Scenario: The current user can be retrieved and updated + Given the client is signed in as the confirmed contract user + When the client retrieves the current user + Then the current user belongs to the contract user + When the client updates the current user's metadata + Then the current user contains the updated metadata + + @auth @SDK-AUTH-004 + Scenario: Refresh rotates tokens and failed refresh clears authentication + Given the client is signed in as the confirmed contract user + When the client refreshes the current session + Then the current session exposes rotated access and refresh tokens + When the client refreshes with an invalid refresh token + Then the SDK operation fails + And the current session is empty + + @auth @SDK-AUTH-005 + Scenario: Sign-out clears local authentication + Given the client is signed in as the confirmed contract user + When the client signs out + Then the SDK operation succeeds + And the current session is empty + + @auth @SDK-AUTH-006 + Scenario: Auth-state listeners observe changes until unsubscribe + Given the client is signed in as the confirmed contract user + When the client subscribes to auth-state changes + Then the listener immediately observes the current user + When the client signs out + Then the listener observes the signed-out state + When the client unsubscribes from auth-state changes + And the client signs in with the contract user's credentials + Then the listener receives no additional events + + @auth @SDK-AUTH-007 + Scenario: An anonymous user can convert to a credentialed account + Given a unique anonymous contract user + When the client signs up anonymously + Then the current session belongs to the anonymous user + When the client converts the anonymous user with credentials + Then the SDK operation succeeds + And the converted user keeps the anonymous user identity + + @auth @SDK-AUTH-011 + Scenario: A user can inspect and delete their sessions + Given the client is signed in as the confirmed contract user on multiple sessions + When the client lists the current user's sessions + Then the session list contains the current session + When the client deletes another current-user session + Then the deleted session is absent from the session list + When the client deletes all current-user sessions + Then the current session is empty diff --git a/features/contract_support.py b/features/contract_support.py index fc626414..97ecd1bb 100644 --- a/features/contract_support.py +++ b/features/contract_support.py @@ -105,6 +105,9 @@ def __init__(self, fixture: dict[str, Any]) -> None: service_key=fixture["service_key"], ) suffix = f"py-{os.getpid()}-{secrets.token_hex(5)}" + self.unique_email = f"{suffix}@example.com" + self.unique_password = f"Sdk-{suffix}!123" + self.metadata_marker = f"updated-{suffix}" self.storage_path = f"{fixture['storage_path']}.{suffix}" self.realtime_channel = f"{fixture['realtime_channel']}-{suffix}" self.lock_key = f"{fixture['lock_key']}-{suffix}" @@ -117,6 +120,14 @@ def __init__(self, fixture: dict[str, Any]) -> None: self.subscriber: Channel | None = None self.publisher: Channel | None = None self.realtime_clients: list[VolcanoClient] = [] + self.secondary_client: VolcanoClient | None = None + self.listener_events: list[str | None] = [] + self.listener_event_count = 0 + self.unsubscribe_auth: Callable[[], None] | None = None + self.previous_access_token: str | None = None + self.previous_refresh_token: str | None = None + self.anonymous_user_id: str | None = None + self.deleted_session_id: str | None = None self.cleanup_callbacks: list[Callable[[], None]] = [] self.loop = asyncio.new_event_loop() diff --git a/features/steps/sdk_contract_steps.py b/features/steps/sdk_contract_steps.py index 60d99ef4..fa354d99 100644 --- a/features/steps/sdk_contract_steps.py +++ b/features/steps/sdk_contract_steps.py @@ -11,6 +11,8 @@ classify_error, ) +_INVALID_AUTH_CREDENTIAL = "invalid-contract-refresh-token" + def _world(context: Any) -> ContractWorld: return context.contract @@ -36,7 +38,14 @@ def sign_in(context: Any) -> None: def operation_succeeds(context: Any) -> None: outcome = _world(context).last_outcome assert outcome is not None - assert outcome.ok, f"SDK operation failed ({outcome.category}): {outcome.error}" + assert outcome.ok, f"SDK operation failed ({outcome.category})" + + +@then("the SDK operation fails") +def operation_fails(context: Any) -> None: + outcome = _world(context).last_outcome + assert outcome is not None + assert not outcome.ok @then("the current session belongs to the contract user") @@ -57,6 +66,249 @@ def session_exposes_tokens(context: Any) -> None: assert session.refresh_token +@given("a unique unconfirmed contract user") +def unique_unconfirmed_user(context: Any) -> None: + world = _world(context) + assert world.unique_email.endswith("@example.com") + + +@when("the client signs up with the new user's credentials") +def sign_up_unique_user(context: Any) -> None: + world = _world(context) + world.record( + lambda: world.client.auth.sign_up( + email=world.unique_email, + password=world.unique_password, + ) + ) + + +@then("sign-up is acknowledged without a session") +def signup_is_sessionless(context: Any) -> None: + world = _world(context) + assert world.last_outcome is not None + assert world.last_outcome.value.session is None + + +@then("the current session is empty") +def current_session_is_empty(context: Any) -> None: + assert _world(context).client.current_session is None + + +@given("the client is signed in as the confirmed contract user") +def signed_in_contract_user(context: Any) -> None: + _world(context).authenticate() + + +@when("the client retrieves the current user") +def retrieve_current_user(context: Any) -> None: + world = _world(context) + world.record(world.client.auth.get_user) + + +@then("the current user belongs to the contract user") +def current_user_matches_fixture(context: Any) -> None: + world = _world(context) + assert world.client.current_user is not None + assert world.client.current_user.id == world.fixture["user_id"] + + +@when("the client updates the current user's metadata") +def update_current_user_metadata(context: Any) -> None: + world = _world(context) + world.record( + lambda: world.client.auth.update_user( + user_metadata={"contract_marker": world.metadata_marker} + ) + ) + + +@then("the current user contains the updated metadata") +def current_user_has_metadata(context: Any) -> None: + world = _world(context) + assert world.client.current_user is not None + assert world.client.current_user.user_metadata is not None + assert ( + world.client.current_user.user_metadata["contract_marker"] + == world.metadata_marker + ) + + +@when("the client refreshes the current session") +def refresh_current_session(context: Any) -> None: + world = _world(context) + session = world.client.current_session + assert session is not None + world.previous_access_token = session.access_token + world.previous_refresh_token = session.refresh_token + world.record(world.client.auth.refresh_session) + + +@then("the current session exposes rotated access and refresh tokens") +def session_tokens_are_rotated(context: Any) -> None: + world = _world(context) + session = world.client.current_session + assert session is not None + assert session.access_token != world.previous_access_token + assert session.refresh_token + assert session.refresh_token != world.previous_refresh_token + + +@when("the client refreshes with an invalid refresh token") +def refresh_with_invalid_token(context: Any) -> None: + world = _world(context) + session = world.client.current_session + assert session is not None + world.client = type(world.client)( + api_url=world.fixture["api_url"], + anon_key=world.fixture["anon_key"], + access_token=session.access_token, + refresh_token=_INVALID_AUTH_CREDENTIAL, + ) + world.record(world.client.auth.refresh_session) + + +@when("the client signs out") +def sign_out(context: Any) -> None: + world = _world(context) + world.record(world.client.auth.sign_out) + + +@when("the client subscribes to auth-state changes") +def subscribe_auth_state(context: Any) -> None: + world = _world(context) + world.unsubscribe_auth = world.client.auth.on_auth_state_change( + lambda user: world.listener_events.append(user.id if user is not None else None) + ) + + +@then("the listener immediately observes the current user") +def listener_observes_current_user(context: Any) -> None: + world = _world(context) + assert world.listener_events == [world.fixture["user_id"]] + + +@then("the listener observes the signed-out state") +def listener_observes_signout(context: Any) -> None: + world = _world(context) + assert world.listener_events[-1] is None + + +@when("the client unsubscribes from auth-state changes") +def unsubscribe_auth_state(context: Any) -> None: + world = _world(context) + assert world.unsubscribe_auth is not None + world.unsubscribe_auth() + world.listener_event_count = len(world.listener_events) + + +@then("the listener receives no additional events") +def listener_receives_no_events(context: Any) -> None: + world = _world(context) + assert len(world.listener_events) == world.listener_event_count + + +@given("a unique anonymous contract user") +def unique_anonymous_user(context: Any) -> None: + world = _world(context) + assert world.unique_email.endswith("@example.com") + + +@when("the client signs up anonymously") +def sign_up_anonymously(context: Any) -> None: + world = _world(context) + outcome = world.record(world.client.auth.sign_up_anonymous) + if outcome.ok: + world.anonymous_user_id = outcome.value.user_id + + +@then("the current session belongs to the anonymous user") +def session_belongs_to_anonymous_user(context: Any) -> None: + world = _world(context) + assert world.client.current_session is not None + assert world.client.current_session.user_id == world.anonymous_user_id + + +@when("the client converts the anonymous user with credentials") +def convert_anonymous_user(context: Any) -> None: + world = _world(context) + world.record( + lambda: world.client.auth.convert_anonymous( + email=world.unique_email, + password=world.unique_password, + ) + ) + + +@then("the converted user keeps the anonymous user identity") +def converted_user_keeps_identity(context: Any) -> None: + world = _world(context) + assert world.client.current_user is not None + assert world.client.current_user.id == world.anonymous_user_id + + +@given("the client is signed in as the confirmed contract user on multiple sessions") +def signed_in_on_multiple_sessions(context: Any) -> None: + world = _world(context) + world.authenticate() + world.secondary_client = type(world.client)( + api_url=world.fixture["api_url"], + anon_key=world.fixture["anon_key"], + ) + world.secondary_client.auth.sign_in( + email=world.fixture["user_email"], + password=world.fixture["user_password"], + ) + + +@when("the client lists the current user's sessions") +def list_current_user_sessions(context: Any) -> None: + world = _world(context) + world.record(world.client.auth.get_sessions) + + +@then("the session list contains the current session") +def session_list_contains_current(context: Any) -> None: + world = _world(context) + assert world.last_outcome is not None + assert any(session.is_current for session in world.last_outcome.value.sessions) + + +@when("the client deletes another current-user session") +def delete_other_session(context: Any) -> None: + world = _world(context) + page = world.client.auth.get_sessions() + other = next(session for session in page.sessions if not session.is_current) + world.deleted_session_id = other.id + + def operation() -> Any: + world.client.auth.delete_session(session_id=other.id) + return world.client.auth.get_sessions() + + world.record(operation) + + +@then("the deleted session is absent from the session list") +def deleted_session_is_absent(context: Any) -> None: + world = _world(context) + assert world.last_outcome is not None + assert all( + session.id != world.deleted_session_id + for session in world.last_outcome.value.sessions + ) + + +@when("the client deletes all current-user sessions") +def delete_all_current_user_sessions(context: Any) -> None: + world = _world(context) + + def operation() -> None: + world.client.auth.delete_all_other_sessions() + world.client.auth.sign_out() + + world.record(operation) + + @given("an authenticated client") def authenticated_client(context: Any) -> None: _world(context).authenticate() diff --git a/tests/unit/test_contract_bindings.py b/tests/unit/test_contract_bindings.py index 7493fb77..b24fa4bd 100644 --- a/tests/unit/test_contract_bindings.py +++ b/tests/unit/test_contract_bindings.py @@ -15,7 +15,7 @@ ROOT = Path(__file__).parents[2] FEATURE_SHA256 = { - "auth.feature": "6e6bcc6244bbdb9b1c141a3f0d8f1256d2be0057429457084a094ed98cbcbd07", + "auth.feature": "0f251b2b39d66a1bf35043b25fd7d4cf750b3e1b2790911c7d26364f82852f9b", "database.feature": ( "4685b29357a621068b25984ff0de29cd4c504eebe5cfb597f0b999e29878a668" ), @@ -60,19 +60,49 @@ def test_every_contract_phrase_is_bound_verbatim() -> None: } assert bound == { "a service-role client", + "a unique anonymous contract user", + "a unique unconfirmed contract user", "an authenticated client", "exactly the fixture row is returned", "one client subscribes and the other publishes the contract message", + "sign-up is acknowledged without a session", + "the client converts the anonymous user with credentials", + "the client deletes all current-user sessions", + "the client deletes another current-user session", + "the client is signed in as the confirmed contract user", + "the client is signed in as the confirmed contract user on multiple sessions", + "the client lists the current user's sessions", + "the client refreshes the current session", + "the client refreshes with an invalid refresh token", + "the client retrieves the current user", + "the client signs out", + "the client signs up anonymously", + "the client signs up with the new user's credentials", + "the client subscribes to auth-state changes", + "the client unsubscribes from auth-state changes", + "the client updates the current user's metadata", + "the converted user keeps the anonymous user identity", + "the current session belongs to the anonymous user", "the SDK operation succeeds", + "the SDK operation fails", "the client acquires and releases the contract lock", 'the client selects the contract table where "slug" equals the fixture slug', "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 rotated access and refresh tokens", + "the current session is empty", "the current session exposes access and refresh tokens", + "the current user belongs to the contract user", + "the current user contains the updated metadata", + "the deleted session is absent from the session list", "the downloaded bytes equal the uploaded bytes", "the released lease is no longer held", + "the listener immediately observes the current user", + "the listener observes the signed-out state", + "the listener receives no additional events", + "the session list contains the current session", "the stored object path equals the contract path", "the subscriber receives the contract message within 10 seconds", "two authenticated realtime clients", From 5076d1ddf88684fc72552294bca4ed2656d950d4 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:22 -0400 Subject: [PATCH 06/37] chore: enforce strict quality gates --- features/contract_support.py | 49 ++++--- pyproject.toml | 27 ++++ src/volcano_sdk/realtime.py | 3 + tests/unit/test_generated_transport.py | 147 ++++++++++++--------- tests/unit/test_realtime.py | 175 ++++++++++++++----------- 5 files changed, 238 insertions(+), 163 deletions(-) diff --git a/features/contract_support.py b/features/contract_support.py index 97ecd1bb..0d69c1aa 100644 --- a/features/contract_support.py +++ b/features/contract_support.py @@ -61,34 +61,29 @@ def classify_error(error: Exception) -> str: (ServerError, "server error"), (TransportError, "transport error"), ) - matched_category = next( - ( - category - for error_type, category in categories - if isinstance(error, error_type) - ), - None, - ) - if matched_category is not None: - return matched_category + for error_type, category in categories: + if isinstance(error, error_type): + return category if isinstance(error, VolcanoError): - status = error.status - if status in (401, 403): - return "authentication error" - if status in (400, 422): - return "validation error" - category_by_status = { - HTTP_NOT_FOUND: "not found", - HTTP_CONFLICT: "conflict", - HTTP_RATE_LIMITED: "rate limited", - } - if status in category_by_status: - return category_by_status[status] - if ( - status is not None - and HTTP_SERVER_ERROR_MIN <= status <= HTTP_SERVER_ERROR_MAX - ): - return "server error" + return _classify_volcano_status(error.status) + return "transport error" + + +def _classify_volcano_status(status: int | None) -> str: + category_by_status = { + 400: "validation error", + 401: "authentication error", + 403: "authentication error", + 422: "validation error", + HTTP_NOT_FOUND: "not found", + HTTP_CONFLICT: "conflict", + HTTP_RATE_LIMITED: "rate limited", + } + category = category_by_status.get(status) + if category is not None: + return category + if status is not None and HTTP_SERVER_ERROR_MIN <= status <= HTTP_SERVER_ERROR_MAX: + return "server error" return "transport error" diff --git a/pyproject.toml b/pyproject.toml index b39884e7..9d7c3a14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,16 @@ files = ["src", "tests/unit", "scripts"] python_version = "3.11" strict = true exclude = ["src/volcano_sdk/_generated/"] +enable_error_code = [ + "explicit-override", + "ignore-without-code", + "mutable-override", + "possibly-undefined", + "redundant-expr", + "truthy-bool", + "truthy-iterable", + "unused-awaitable", +] [[tool.mypy.overrides]] module = ["behave.*"] @@ -58,6 +68,11 @@ typeCheckingMode = "strict" # Package-level collaborators intentionally share private implementation details; # Ruff still enforces private access everywhere else. reportPrivateUsage = false +reportImplicitOverride = "error" +reportMatchNotExhaustive = "error" +reportPropertyTypeMismatch = "error" +reportUnnecessaryTypeIgnoreComment = "error" +reportUnreachable = "error" [tool.ruff] target-version = "py311" @@ -87,6 +102,18 @@ ignore = [ "W191", ] +[tool.ruff.lint.mccabe] +max-complexity = 6 + +[tool.ruff.lint.pylint] +max-bool-expr = 3 +max-branches = 6 +max-locals = 10 +max-nested-blocks = 3 +max-returns = 4 +max-statements = 30 +max-statements-in-try = 3 + [tool.ruff.lint.per-file-ignores] "features/**/*.py" = ["ANN401", "D", "INP001", "S101"] "scripts/*.py" = ["T201"] diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 780bbbf3..0f849e99 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -9,6 +9,8 @@ from typing import Any, Protocol, cast from urllib.parse import quote, urlsplit, urlunsplit +from typing_extensions import override + MessageCallback = Callable[[Any], Any] CALLBACK_QUEUE_LIMIT = 128 CALLBACK_QUEUE_FULL_MESSAGE = ( @@ -120,6 +122,7 @@ def _centrifuge_client( class _ProjectAwareSubscriptions(dict[str, Any]): + @override def get(self, key: str, default: Any = None) -> Any: subscription = super().get(key) if subscription is not None: diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 25ac7711..db9dc0b9 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -4,7 +4,7 @@ import httpx -from volcano_sdk._transport import GeneratedTransport +from volcano_sdk._transport import GeneratedTransport, TransportResponse def _recording_transport() -> tuple[GeneratedTransport, list[httpx.Request]]: @@ -23,69 +23,86 @@ def handle(request: httpx.Request) -> httpx.Response: ) -def test_generated_transport_calls_the_six_openapi_operations() -> None: +def _auth_response() -> httpx.Response: + return httpx.Response( + 200, + json={ + "access_token": "access-token", + "refresh_token": "refresh-token", + "user": { + "id": "00000000-0000-4000-8000-000000000010", + "email": "user@example.com", + "status": "active", + "email_confirmed": True, + "created_at": "2026-08-26T12:00:00Z", + "updated_at": "2026-08-26T12:00:00Z", + }, + "expires_in": 3600, + "token_type": "bearer", + }, + ) + + +def _upload_response() -> httpx.Response: + return httpx.Response( + 201, + json={ + "id": "00000000-0000-4000-8000-000000000020", + "bucket_id": "00000000-0000-4000-8000-000000000030", + "name": "a.txt", + "is_public": False, + "size": 5, + "mime_type": "application/octet-stream", + "metadata": {}, + "owner_id": "00000000-0000-4000-8000-000000000010", + "created_at": "2026-08-26T12:00:00Z", + "updated_at": "2026-08-26T12:00:00Z", + }, + ) + + +def _successful_response(request: httpx.Request) -> httpx.Response: + response_by_operation = { + ("POST", "/auth/signin"): _auth_response(), + ("POST", "/databases/main/query/select"): httpx.Response( + 200, json={"data": [{"slug": "a"}], "count": 1} + ), + ("POST", "/storage/assets/a.txt"): _upload_response(), + ("GET", "/storage/assets/a.txt"): httpx.Response(200, content=b"hello"), + ("POST", "/locks/build/lease"): httpx.Response( + 201, + json={ + "key": "build", + "expires_at": "2026-08-26T12:00:30Z", + "fencing_token": 7, + }, + ), + ("DELETE", "/locks/build/lease"): httpx.Response(204), + } + response = response_by_operation.get((request.method, request.url.path)) + if response is None: + message = f"unexpected request: {request.method} {request.url.path}" + raise AssertionError(message) + return response + + +def _successful_transport() -> tuple[GeneratedTransport, list[httpx.Request]]: requests: list[httpx.Request] = [] def handle(request: httpx.Request) -> httpx.Response: requests.append(request) - path = request.url.path - if path == "/auth/signin": - return httpx.Response( - 200, - json={ - "access_token": "access-token", - "refresh_token": "refresh-token", - "user": { - "id": "00000000-0000-4000-8000-000000000010", - "email": "user@example.com", - "status": "active", - "email_confirmed": True, - "created_at": "2026-08-26T12:00:00Z", - "updated_at": "2026-08-26T12:00:00Z", - }, - "expires_in": 3600, - "token_type": "bearer", - }, - ) - if path == "/databases/main/query/select": - return httpx.Response(200, json={"data": [{"slug": "a"}], "count": 1}) - if request.method == "POST" and path == "/storage/assets/a.txt": - return httpx.Response( - 201, - json={ - "id": "00000000-0000-4000-8000-000000000020", - "bucket_id": "00000000-0000-4000-8000-000000000030", - "name": "a.txt", - "is_public": False, - "size": 5, - "mime_type": "application/octet-stream", - "metadata": {}, - "owner_id": "00000000-0000-4000-8000-000000000010", - "created_at": "2026-08-26T12:00:00Z", - "updated_at": "2026-08-26T12:00:00Z", - }, - ) - if request.method == "GET" and path == "/storage/assets/a.txt": - return httpx.Response(200, content=b"hello") - if request.method == "POST" and path == "/locks/build/lease": - return httpx.Response( - 201, - json={ - "key": "build", - "expires_at": "2026-08-26T12:00:30Z", - "fencing_token": 7, - }, - ) - if request.method == "DELETE" and path == "/locks/build/lease": - return httpx.Response(204) - message = f"unexpected request: {request.method} {path}" - raise AssertionError(message) + return _successful_response(request) transport = GeneratedTransport( api_url="https://api.test.volcano.dev", httpx_transport=httpx.MockTransport(handle), ) + return transport, requests + +def _call_six_operations( + transport: GeneratedTransport, +) -> tuple[TransportResponse, ...]: auth = transport.auth_signin( authorization="anon-key", email="user@example.com", @@ -122,13 +139,10 @@ def handle(request: httpx.Request) -> httpx.Response: key="build", token="00000000-0000-4000-8000-000000000001", ) + return auth, query, upload, download, acquire, release - assert auth.payload["user"]["id"] == "00000000-0000-4000-8000-000000000010" - assert query.payload == {"data": [{"slug": "a"}], "count": 1} - assert upload.payload["name"] == "a.txt" - assert download.content == b"hello" - assert acquire.payload["fencing_token"] == 7 - assert release.status_code == 204 + +def _assert_request_metadata(requests: list[httpx.Request]) -> None: assert [request.method for request in requests] == [ "POST", "POST", @@ -164,6 +178,19 @@ def handle(request: httpx.Request) -> httpx.Response: ) +def test_generated_transport_calls_the_six_openapi_operations() -> None: + transport, requests = _successful_transport() + auth, query, upload, download, acquire, release = _call_six_operations(transport) + + assert auth.payload["user"]["id"] == "00000000-0000-4000-8000-000000000010" + assert query.payload == {"data": [{"slug": "a"}], "count": 1} + assert upload.payload["name"] == "a.txt" + assert download.content == b"hello" + assert acquire.payload["fencing_token"] == 7 + assert release.status_code == 204 + _assert_request_metadata(requests) + + def test_generated_transport_calls_session_core_operations() -> None: transport, requests = _recording_transport() diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 805d9f35..71a8abcf 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -1,11 +1,12 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field from types import SimpleNamespace from typing import TYPE_CHECKING, Any import pytest +from typing_extensions import override from volcano_sdk import VolcanoClient @@ -159,24 +160,59 @@ def __call__( return self.client -def test_realtime_wraps_official_client_without_exposing_it() -> None: - transport = AuthTransport() - official = FakeCentrifugeClient() - factory_arguments: dict[str, Any] = {} +@dataclass +class CapturingCentrifugeFactory: + client: FakeCentrifugeClient + address: str | None = field(default=None, init=False) + token: str | None = field(default=None, init=False) + get_token: Callable[[], Awaitable[str]] | None = field(default=None, init=False) - def factory( + def __call__( + self, address: str, *, token: str, get_token: Callable[[], Awaitable[str]], ) -> FakeCentrifugeClient: - factory_arguments.update( - address=address, - token=token, - get_token=get_token, - ) - return official + self.address = address + self.token = token + self.get_token = get_token + return self.client + + +async def _exercise_realtime_facade( + client: VolcanoClient, + official: FakeCentrifugeClient, + transport: AuthTransport, + received: list[dict[str, str]], + factory: CapturingCentrifugeFactory, +) -> None: + channel = client.realtime.channel("contract") + assert channel.on("message", received.append) is channel + await channel.subscribe() + assert official.subscription is not None + await official.emit_wire_publication( + "project-id:broadcast:contract", + {"event": "message", "value": "contract"}, + ) + for _ in range(10): + if received: + break + await asyncio.sleep(0) + assert received == [{"event": "message", "value": "contract"}] + await channel.send({"event": "message", "value": "contract"}) + await channel.unsubscribe() + transport.access_token = "access-2" + client.auth.sign_in(email="user@example.com", password="secret") + assert factory.get_token is not None + assert await factory.get_token() == "access-2" + await client.realtime.disconnect() + +def test_realtime_wraps_official_client_without_exposing_it() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + factory = CapturingCentrifugeFactory(official) client = VolcanoClient( api_url="https://api.test.volcano.dev", anon_key="anon key", @@ -185,35 +221,14 @@ def factory( ) client.auth.sign_in(email="user@example.com", password="secret") received: list[dict[str, str]] = [] + asyncio.run( + _exercise_realtime_facade(client, official, transport, received, factory) + ) - async def scenario() -> None: - channel = client.realtime.channel("contract") - assert channel.on("message", received.append) is channel - await channel.subscribe() - assert official.subscription is not None - await official.emit_wire_publication( - "project-id:broadcast:contract", - {"event": "message", "value": "contract"}, - ) - for _ in range(10): - if received: - break - await asyncio.sleep(0) - assert received == [{"event": "message", "value": "contract"}] - await channel.send({"event": "message", "value": "contract"}) - await channel.unsubscribe() - - transport.access_token = "access-2" - client.auth.sign_in(email="user@example.com", password="secret") - assert await factory_arguments["get_token"]() == "access-2" - await client.realtime.disconnect() - - asyncio.run(scenario()) - - assert factory_arguments["address"] == ( + assert factory.address == ( "wss://api.test.volcano.dev/realtime/v1/websocket?apikey=anon%20key" ) - assert factory_arguments["token"] == "access-1" + assert factory.token == "access-1" assert official.calls == ["connect", "channel:broadcast:contract", "disconnect"] assert official.subscription is not None assert official.subscription.calls == [ @@ -336,6 +351,50 @@ async def scenario() -> None: asyncio.run(scenario()) +@dataclass +class FailingFirstCallback: + received: list[str] + + def __call__(self, data: dict[str, str]) -> None: + if data["value"] == "first": + message = "callback failed" + raise RuntimeError(message) + self.received.append(data["value"]) + + +async def _exercise_callback_failure( + client: VolcanoClient, + official: FakeCentrifugeClient, +) -> None: + channel = client.realtime.channel("contract") + received: list[str] = [] + errors: list[dict[str, Any]] = [] + loop = asyncio.get_running_loop() + previous_handler = loop.get_exception_handler() + loop.set_exception_handler(lambda _loop, context: errors.append(context)) + try: + channel.on("message", FailingFirstCallback(received)) + await channel.subscribe() + await official.emit_wire_publication( + "broadcast:contract", + {"event": "message", "value": "first"}, + ) + await official.emit_wire_publication( + "broadcast:contract", + {"event": "message", "value": "second"}, + ) + for _ in range(10): + if received: + break + await asyncio.sleep(0) + assert received == ["second"] + assert len(errors) == 1 + assert isinstance(errors[0].get("exception"), RuntimeError) + finally: + loop.set_exception_handler(previous_handler) + await client.realtime.disconnect() + + def test_realtime_callback_failure_does_not_stop_later_callbacks() -> None: transport = AuthTransport() official = FakeCentrifugeClient() @@ -345,44 +404,7 @@ def test_realtime_callback_failure_does_not_stop_later_callbacks() -> None: _realtime_client_factory=FakeCentrifugeFactory(official), ) client.auth.sign_in(email="user@example.com", password="secret") - - async def scenario() -> None: - channel = client.realtime.channel("contract") - received: list[str] = [] - errors: list[dict[str, Any]] = [] - loop = asyncio.get_running_loop() - previous_handler = loop.get_exception_handler() - loop.set_exception_handler(lambda _loop, context: errors.append(context)) - - def callback(data: dict[str, str]) -> None: - if data["value"] == "first": - message = "callback failed" - raise RuntimeError(message) - received.append(data["value"]) - - try: - channel.on("message", callback) - await channel.subscribe() - await official.emit_wire_publication( - "broadcast:contract", - {"event": "message", "value": "first"}, - ) - await official.emit_wire_publication( - "broadcast:contract", - {"event": "message", "value": "second"}, - ) - for _ in range(10): - if received: - break - await asyncio.sleep(0) - assert received == ["second"] - assert len(errors) == 1 - assert isinstance(errors[0].get("exception"), RuntimeError) - finally: - loop.set_exception_handler(previous_handler) - await client.realtime.disconnect() - - asyncio.run(scenario()) + asyncio.run(_exercise_callback_failure(client, official)) def test_realtime_callback_can_disconnect_its_own_client() -> None: @@ -514,6 +536,7 @@ def test_realtime_disconnect_excludes_subscription_on_an_existing_connection( release = asyncio.Event() class BlockingSubscription(FakeSubscription): + @override async def subscribe(self) -> None: entered.set() await release.wait() From a8968ecd04feee3e4ad94edb53b0e869b023f376 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:22:17 -0400 Subject: [PATCH 07/37] fix: address authentication review findings --- docs/authentication.md | 6 +++++- src/volcano_sdk/auth.py | 26 ++++++++++++++++++++++---- tests/unit/test_auth.py | 40 ++++++++++++++++++++++++++++++---------- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 3ab44dcb..f0446d05 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -17,7 +17,7 @@ session = client.auth.sign_in( ) print(client.current_user.id) -print(session.expires_at) +print(session.expires_in) ``` The client keeps the active `current_user` and `current_session` in memory. @@ -132,6 +132,10 @@ request = client.auth.get_hosted_auth_url( print(request.authorization_url) ``` +When hosted auth redirects back, compare the returned state with +`request.state` before adopting any returned credentials. The Python SDK does +not consume browser callbacks automatically. + OAuth authorization follows the same pattern. Preserve `request.state` and pass it as `expected_state` during exchange; the SDK rejects a mismatch before calling the API. diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index fc0b1e39..7e43748d 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -8,6 +8,7 @@ from secrets import token_urlsafe from typing import TYPE_CHECKING, Any, Literal, Protocol, cast from urllib.parse import quote, urlencode +from uuid import UUID from ._transport import Transport, TransportResponse, invoke, response_payload from .errors import AuthenticationError, ValidationError @@ -36,6 +37,7 @@ _INVALID_OAUTH_STATE = "OAuth state does not match" _INVALID_OAUTH_PROVIDER = "Unsupported OAuth provider" _MISSING_AUTHORIZATION_URL = "Authentication response is missing authorization URL" +_INVALID_SESSION_ID = "session_id must be a valid UUID" _SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) @@ -227,6 +229,7 @@ class Auth: def __init__(self, client: AuthContext) -> None: """Create an authentication facade backed by a client.""" self._client = client + self._current_device_session_ids: set[str] = set() def sign_up( self, @@ -422,7 +425,9 @@ def reset_password(self, *, token: str, new_password: str) -> MessageResult: token=token, new_password=new_password, ) - return _message(_mapping(response_payload(response, 200))) + result = _message(_mapping(response_payload(response, 200))) + self._client._clear_auth() + return result def request_email_change(self, *, new_email: str) -> EmailChangeResult: """Request a change to the current user's email address.""" @@ -450,7 +455,9 @@ def confirm_email_change(self, *, token: str) -> MessageResult: expected_status=200, email_change_token=token, ) - return _message(_mapping(payload)) + response = _mapping(payload) + self._client._set_user(_user(_mapping(response.get("user")))) + return _message(response) def cancel_email_change(self) -> MessageResult: """Cancel the current user's pending email change.""" @@ -494,7 +501,7 @@ def get_oauth_authorization_url( redirect_url=redirect_url, state=state, ) - response_payload(response, 302) + response_payload(response, 307) authorization_url = self._response_header(response, "Location") if authorization_url is None: raise AuthenticationError(_MISSING_AUTHORIZATION_URL) @@ -638,8 +645,12 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: if not isinstance(sessions_value, list): raise AuthenticationError(_INVALID_AUTH_RESPONSE) sessions = cast("list[object]", sessions_value) + mapped_sessions = tuple(_auth_session(_mapping(item)) for item in sessions) + self._current_device_session_ids = { + session.id for session in mapped_sessions if session.is_current + } return SessionPage( - sessions=tuple(_auth_session(_mapping(item)) for item in sessions), + sessions=mapped_sessions, total=_optional_int(payload.get("total")), page=_optional_int(payload.get("page")), limit=_optional_int(payload.get("limit")), @@ -648,11 +659,18 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: def delete_session(self, *, session_id: str) -> None: """Delete one device session.""" + try: + UUID(session_id) + except (TypeError, ValueError, AttributeError) as error: + raise ValidationError(_INVALID_SESSION_ID) from error self._authenticated_payload( self._client._transport.auth_delete_my_session, expected_status=204, session_id=session_id, ) + if session_id in self._current_device_session_ids: + self._current_device_session_ids.clear() + self._client._clear_auth() def delete_all_other_sessions(self) -> None: """Delete every device session except the current one.""" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 73201c43..2fe0ed6f 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -422,6 +422,7 @@ def test_get_and_update_user_preserve_the_current_session() -> None: refresh_token="refresh-token", _transport=transport, ) + session = client.current_session fetched = client.auth.get_user() @@ -645,10 +646,13 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: "auth_resend_confirmation", "auth_forgot_password", "auth_reset_password", - "auth_confirm_email_change", "auth_cancel_email_change", ): transport.queue(operation, AuthResponse(200, {"message": "Done"})) + transport.queue( + "auth_confirm_email_change", + AuthResponse(200, {"message": "Done", "user": _user_payload()}), + ) transport.queue( "auth_request_email_change", AuthResponse( @@ -672,18 +676,19 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: client.auth.confirm_email(token="confirmation-token"), client.auth.resend_confirmation(email="user@example.com"), client.auth.forgot_password(email="user@example.com"), - client.auth.reset_password( - token="recovery-token", - new_password="new-password", - ), ) email_change = client.auth.request_email_change(new_email="new@example.com") confirm_change = client.auth.confirm_email_change(token="email-change-token") cancel_change = client.auth.cancel_email_change() + user_before_reset = client.current_user + password_reset = client.auth.reset_password( + token="recovery-token", + new_password="new-password", + ) assert anonymous is not None assert anonymous.access_token == "anonymous-access" - assert converted is client.current_user + assert user_before_reset == converted assert all(result == MessageResult(message="Done") for result in messages) assert email_change == EmailChangeResult( message="Check your email", @@ -693,6 +698,8 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: assert "development-token" not in repr(email_change) assert confirm_change == MessageResult(message="Done") assert cancel_change == MessageResult(message="Done") + assert password_reset == MessageResult(message="Done") + assert client.current_session is None assert transport.calls[:2] == [ ( "auth_signup_anonymous", @@ -724,7 +731,7 @@ def fixed_state(_size: int) -> str: transport.queue( "auth_oauth_authorize", AuthResponse( - 302, + 307, headers={"Location": "https://github.com/login/oauth/authorize"}, ), ) @@ -879,7 +886,7 @@ def test_provider_and_device_session_flows_return_public_values() -> None: { "sessions": [ { - "id": "session-123", + "id": "3cd3e058-e3ff-42a5-ae4d-650ef9b45746", "user_id": "user-123", "provider": "email", "expires_at": "2026-08-29T12:00:00Z", @@ -912,8 +919,8 @@ def test_provider_and_device_session_flows_return_public_values() -> None: endpoint="/user", ) sessions = client.auth.get_sessions(page=1, limit=20) - client.auth.delete_session(session_id="session-123") client.auth.delete_all_other_sessions() + client.auth.delete_session(session_id="3cd3e058-e3ff-42a5-ae4d-650ef9b45746") assert providers == ( OAuthProvider( @@ -932,7 +939,7 @@ def test_provider_and_device_session_flows_return_public_values() -> None: assert sessions == SessionPage( sessions=( AuthSession( - id="session-123", + id="3cd3e058-e3ff-42a5-ae4d-650ef9b45746", user_id="user-123", provider="email", expires_at=datetime(2026, 8, 29, 12, tzinfo=UTC), @@ -945,3 +952,16 @@ def test_provider_and_device_session_flows_return_public_values() -> None: limit=20, total_pages=1, ) + assert client.current_session is None + + +def test_delete_session_rejects_a_malformed_identifier() -> None: + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=AuthTransport(), + ) + + with pytest.raises(ValidationError, match="session_id must be a valid UUID"): + client.auth.delete_session(session_id="not-a-uuid") From 2e0416e0c2f8a9b77fa3ada34ce2866937adfbae Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:29:36 -0400 Subject: [PATCH 08/37] fix: synchronize authentication state --- docs/authentication.md | 2 +- src/volcano_sdk/auth.py | 14 +++++----- tests/unit/test_auth.py | 57 ++++++++++++++++++++++++++++++++++++++--- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index f0446d05..32ffd7fa 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -64,7 +64,7 @@ List and revoke device sessions through the same facade: page = client.auth.get_sessions(page=1, limit=20) for device_session in page.sessions: - print(device_session.id, device_session.last_seen_at) + print(device_session.id, device_session.last_activity_at) client.auth.delete_session(session_id="session-id") client.auth.delete_all_other_sessions() diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 7e43748d..dc7e46b1 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -397,7 +397,10 @@ def confirm_email(self, *, token: str) -> MessageResult: authorization=self._client._anon_token(), token=token, ) - return _message(_mapping(response_payload(response, 200))) + result = _message(_mapping(response_payload(response, 200))) + if self._client.current_session is not None: + self.get_user() + return result def resend_confirmation(self, *, email: str) -> MessageResult: """Request another email-confirmation message.""" @@ -704,11 +707,10 @@ def _authenticated_payload( return response_payload(response, expected_status) except AuthenticationError as error: session = self._client.current_session - if ( - error.status != _HTTP_UNAUTHORIZED - or session is None - or session.refresh_token is None - ): + if error.status != _HTTP_UNAUTHORIZED or session is None: + raise + if session.refresh_token is None: + self._client._clear_auth() raise self.refresh_session() response = invoke( diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 2fe0ed6f..0fda2347 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -34,12 +34,16 @@ class AuthResponse: headers: dict[str, str] | None = None -def _user_payload(*, email: str = "user@example.com") -> dict[str, Any]: +def _user_payload( + *, + email: str = "user@example.com", + email_confirmed: bool = True, +) -> dict[str, Any]: return { "id": "user-123", "email": email, "project_id": "project-123", - "email_confirmed": True, + "email_confirmed": email_confirmed, "user_metadata": {"display_name": "User"}, "app_metadata": {"role": "developer"}, "avatar_url": "https://example.com/avatar.png", @@ -558,6 +562,26 @@ def test_authenticated_request_refreshes_once_and_replays() -> None: ] +def test_rejected_access_only_session_clears_local_auth() -> None: + transport = AuthTransport() + transport.queue( + "auth_get_user", + AuthResponse(401, {"error": "Access token expired"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + client._set_user(User(id="user-123", email="user@example.com")) + + with pytest.raises(AuthenticationError, match="Access token expired"): + client.auth.get_user() + + assert client.current_session is None + assert client.current_user is None + + def test_sign_out_always_clears_local_auth() -> None: transport = AuthTransport() transport.queue("auth_logout", AuthResponse(204)) @@ -642,7 +666,6 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: AuthResponse(200, {"user": _user_payload()}), ) for operation in ( - "auth_confirm_email", "auth_resend_confirmation", "auth_forgot_password", "auth_reset_password", @@ -673,7 +696,6 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: user_metadata={"plan": "developer"}, ) messages = ( - client.auth.confirm_email(token="confirmation-token"), client.auth.resend_confirmation(email="user@example.com"), client.auth.forgot_password(email="user@example.com"), ) @@ -720,6 +742,33 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: ] +def test_confirm_email_refreshes_current_user_and_notifies_listeners() -> None: + transport = AuthTransport() + transport.queue("auth_confirm_email", AuthResponse(200, {"message": "Done"})) + transport.queue( + "auth_get_user", + AuthResponse(200, {"user": _user_payload(email_confirmed=True)}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + client._set_user( + User(id="user-123", email="user@example.com", email_confirmed=False) + ) + observations: list[User | None] = [] + client.auth.on_auth_state_change(observations.append) + + result = client.auth.confirm_email(token="confirmation-token") + + assert result == MessageResult(message="Done") + assert client.current_user is not None + assert client.current_user.email_confirmed is True + assert observations[-1] is client.current_user + + def test_hosted_and_oauth_authorization_urls_bind_caller_state( monkeypatch: pytest.MonkeyPatch, ) -> None: From b1b64d8ae7122a29f394e5cbc4466f9dfe17ffd0 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:57:57 -0400 Subject: [PATCH 09/37] fix: make authentication state atomic --- src/volcano_sdk/auth.py | 47 ++++++++++--------- src/volcano_sdk/client.py | 58 ++++++++++++++++-------- src/volcano_sdk/realtime.py | 50 +++++++++++++++++++-- tests/unit/test_auth.py | 90 ++++++++++++++++++++++++++++++++++++- tests/unit/test_realtime.py | 49 ++++++++++++++++++++ tests/unit/test_state.py | 5 +++ 6 files changed, 255 insertions(+), 44 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index dc7e46b1..c9a6940b 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Mapping +from contextlib import suppress from datetime import datetime from hmac import compare_digest from secrets import token_urlsafe @@ -11,7 +12,7 @@ from uuid import UUID from ._transport import Transport, TransportResponse, invoke, response_payload -from .errors import AuthenticationError, ValidationError +from .errors import AuthenticationError, ValidationError, VolcanoError from .models import ( AuthorizationRequest, AuthSession, @@ -273,7 +274,7 @@ def sign_in(self, *, email: str, password: str) -> Session: password=password, ) session, user = _session_and_user(_mapping(response_payload(response, 200))) - self._client._commit_auth(session, user) + self._replace_auth(session, user) return session def sign_out(self) -> None: @@ -288,7 +289,7 @@ def sign_out(self) -> None: ) response_payload(response, 204) finally: - self._client._clear_auth() + self._clear_auth() def get_user(self) -> User: """Load the current user from the API.""" @@ -325,7 +326,7 @@ def refresh_session(self) -> Session: """Rotate the current refresh token and replace local auth state.""" session = self._client.current_session if session is None or session.refresh_token is None: - self._client._clear_auth() + self._clear_auth() raise AuthenticationError(_MISSING_AUTH_STATE) succeeded = False @@ -340,12 +341,12 @@ def refresh_session(self) -> Session: ) if refreshed.refresh_token is None: raise AuthenticationError(_INVALID_AUTH_RESPONSE) - self._client._commit_auth(refreshed, user) + self._replace_auth(refreshed, user) succeeded = True return refreshed finally: if not succeeded: - self._client._clear_auth() + self._clear_auth() def on_auth_state_change( self, @@ -366,7 +367,7 @@ def sign_up_anonymous( user_metadata=user_metadata, ) session, user = _session_and_user(_mapping(response_payload(response, 201))) - self._client._commit_auth(session, user) + self._replace_auth(session, user) return session def convert_anonymous( @@ -386,9 +387,9 @@ def convert_anonymous( user_metadata=user_metadata, ) ) - user = _user(_mapping(payload.get("user"))) - self._client._set_user(user) - return user + converted_user = _user(_mapping(payload.get("user"))) + self.refresh_session() + return self._client.current_user or converted_user def confirm_email(self, *, token: str) -> MessageResult: """Confirm an email address with its one-time token.""" @@ -399,7 +400,8 @@ def confirm_email(self, *, token: str) -> MessageResult: ) result = _message(_mapping(response_payload(response, 200))) if self._client.current_session is not None: - self.get_user() + with suppress(VolcanoError): + self.get_user() return result def resend_confirmation(self, *, email: str) -> MessageResult: @@ -428,9 +430,7 @@ def reset_password(self, *, token: str, new_password: str) -> MessageResult: token=token, new_password=new_password, ) - result = _message(_mapping(response_payload(response, 200))) - self._client._clear_auth() - return result + return _message(_mapping(response_payload(response, 200))) def request_email_change(self, *, new_email: str) -> EmailChangeResult: """Request a change to the current user's email address.""" @@ -531,7 +531,7 @@ def exchange_oauth_code( redirect_url=redirect_url, ) session, user = _session_and_user(_mapping(response_payload(response, 200))) - self._client._commit_auth(session, user) + self._replace_auth(session, user) return session def link_oauth_provider( @@ -649,9 +649,9 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: raise AuthenticationError(_INVALID_AUTH_RESPONSE) sessions = cast("list[object]", sessions_value) mapped_sessions = tuple(_auth_session(_mapping(item)) for item in sessions) - self._current_device_session_ids = { + self._current_device_session_ids.update( session.id for session in mapped_sessions if session.is_current - } + ) return SessionPage( sessions=mapped_sessions, total=_optional_int(payload.get("total")), @@ -672,8 +672,7 @@ def delete_session(self, *, session_id: str) -> None: session_id=session_id, ) if session_id in self._current_device_session_ids: - self._current_device_session_ids.clear() - self._client._clear_auth() + self._clear_auth() def delete_all_other_sessions(self) -> None: """Delete every device session except the current one.""" @@ -710,7 +709,7 @@ def _authenticated_payload( if error.status != _HTTP_UNAUTHORIZED or session is None: raise if session.refresh_token is None: - self._client._clear_auth() + self._clear_auth() raise self.refresh_session() response = invoke( @@ -719,3 +718,11 @@ def _authenticated_payload( **kwargs, ) return response_payload(response, expected_status) + + def _replace_auth(self, session: Session, user: User) -> None: + self._current_device_session_ids.clear() + self._client._commit_auth(session, user) + + def _clear_auth(self) -> None: + self._current_device_session_ids.clear() + self._client._clear_auth() diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 9c4efec9..ca958d12 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -4,6 +4,7 @@ import logging from contextlib import suppress +from threading import RLock from typing import TYPE_CHECKING, TypedDict, Unpack, cast from ._transport import GeneratedTransport, Transport @@ -23,6 +24,7 @@ _AUTH_LISTENER_FAILED = "Authentication state listener failed" _LOGGER = logging.getLogger(__name__) +_AUTH_BOOTSTRAP_KEYS = frozenset({"access_token", "refresh_token"}) class _AuthBootstrap(TypedDict, total=False): @@ -48,6 +50,11 @@ def __init__( self._api_url = api_url.rstrip("/") self._anon_key = anon_key self._service_key = service_key + unknown_auth = auth_bootstrap.keys() - _AUTH_BOOTSTRAP_KEYS + if unknown_auth: + unexpected = next(iter(unknown_auth)) + message = f"unexpected authentication keyword: {unexpected}" + raise TypeError(message) access_token = auth_bootstrap.get("access_token") refresh_token = auth_bootstrap.get("refresh_token") if access_token is None and refresh_token is not None: @@ -58,6 +65,7 @@ def __init__( else None ) self._current_user: User | None = None + self._auth_state_lock = RLock() self._auth_listeners: dict[int, Callable[[User | None], None]] = {} self._next_auth_listener_id = 0 self._transport: Transport = ( @@ -80,12 +88,14 @@ def __init__( @property def current_session(self) -> Session | None: """Return the authenticated session, if one exists.""" - return self._current_session + with self._auth_state_lock: + return self._current_session @property def current_user(self) -> User | None: """Return the authenticated user, if one has been loaded.""" - return self._current_user + with self._auth_state_lock: + return self._current_user def database(self, name: str) -> Database: """Create a query facade for a project database.""" @@ -95,9 +105,10 @@ def _anon_token(self) -> str: return self._anon_key def _session_token(self) -> str: - if self._current_session is None: - raise RuntimeError(_NO_ACTIVE_SESSION) - return self._current_session.access_token + with self._auth_state_lock: + if self._current_session is None: + raise RuntimeError(_NO_ACTIVE_SESSION) + return self._current_session.access_token def _service_token(self) -> str: if self._service_key is None: @@ -105,33 +116,42 @@ def _service_token(self) -> str: return self._service_key def _set_session(self, session: Session) -> None: - self._current_session = session + with self._auth_state_lock: + self._current_session = session + self.realtime.on_auth_change() def _commit_auth(self, session: Session, user: User) -> None: - self._current_session = session - self._current_user = user - self._notify_auth_listeners() + with self._auth_state_lock: + self._current_session = session + self._current_user = user + self.realtime.on_auth_change() + self._notify_auth_listeners() def _set_user(self, user: User) -> None: - self._current_user = user - self._notify_auth_listeners() + with self._auth_state_lock: + self._current_user = user + self._notify_auth_listeners() def _clear_auth(self) -> None: - self._current_session = None - self._current_user = None - self._notify_auth_listeners() + with self._auth_state_lock: + self._current_session = None + self._current_user = None + self.realtime.on_auth_change() + self._notify_auth_listeners() def _subscribe_auth( self, listener: Callable[[User | None], None], ) -> Callable[[], None]: - listener_id = self._next_auth_listener_id - self._next_auth_listener_id += 1 - self._auth_listeners[listener_id] = listener - self._invoke_auth_listener(listener) + with self._auth_state_lock: + listener_id = self._next_auth_listener_id + self._next_auth_listener_id += 1 + self._auth_listeners[listener_id] = listener + self._invoke_auth_listener(listener) def unsubscribe() -> None: - self._auth_listeners.pop(listener_id, None) + with self._auth_state_lock: + self._auth_listeners.pop(listener_id, None) return unsubscribe diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 0f849e99..5cee13f1 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -5,6 +5,7 @@ import asyncio import importlib import inspect +import logging from collections.abc import Awaitable, Callable from typing import Any, Protocol, cast from urllib.parse import quote, urlsplit, urlunsplit @@ -20,6 +21,7 @@ SUBSCRIPTION_REGISTRY_UNAVAILABLE = ( "centrifuge client subscription registry is unavailable" ) +_LOGGER = logging.getLogger(__name__) class RealtimeContext(Protocol): @@ -161,11 +163,13 @@ def new_subscription( class _ChannelEvents: - def __init__(self, channel: Channel) -> None: + def __init__(self, channel: Channel, generation: int) -> None: self._channel = channel + self._generation = generation async def on_publication(self, ctx: PublicationContext) -> None: - await self._channel._emit(ctx.pub.data) + if self._generation == self._channel._auth_generation: + await self._channel._emit(ctx.pub.data) async def on_subscribing(self, ctx: Any) -> None: del ctx @@ -201,6 +205,7 @@ def __init__(self, realtime: Realtime, name: str) -> None: self._callback_task: asyncio.Task[None] | None = None self._active_callback_task: asyncio.Task[None] | None = None self._callback_stop: asyncio.Event | None = None + self._auth_generation = 0 def on(self, event: str, callback: MessageCallback) -> Channel: """Register a callback for broadcast messages.""" @@ -284,7 +289,7 @@ async def _run_callback(self, callback: MessageCallback, data: Any) -> None: await result async def _reset(self) -> None: - self._subscription = None + self._invalidate_authentication() task = self._callback_task active_task = self._active_callback_task if self._callback_stop is not None: @@ -299,6 +304,15 @@ async def _reset(self) -> None: self._callback_queue.get_nowait() self._callback_queue.task_done() + def _invalidate_authentication(self) -> None: + self._auth_generation += 1 + self._subscription = None + if self._active_callback_task is not None: + self._active_callback_task.cancel() + while not self._callback_queue.empty(): + self._callback_queue.get_nowait() + self._callback_queue.task_done() + class Realtime: """Manage project realtime connections and channels.""" @@ -317,6 +331,7 @@ def __init__( self._connection: CentrifugeConnection | None = None self._connection_lock = asyncio.Lock() self._channels: dict[str, Channel] = {} + self._auth_cleanup_tasks: set[asyncio.Task[None]] = set() def channel(self, name: str) -> Channel: """Return a stable channel facade for a broadcast name.""" @@ -358,7 +373,7 @@ async def _subscribe(self, channel: Channel) -> None: if channel._subscription is None: channel._subscription = connection.new_subscription( channel._name, - events=_ChannelEvents(channel), + events=_ChannelEvents(channel, channel._auth_generation), ) await channel._subscription.subscribe() @@ -384,3 +399,30 @@ async def disconnect(self) -> None: finally: for channel in tuple(self._channels.values()): await channel._reset() + + def on_auth_change(self) -> None: + """Immediately invalidate work authenticated by the previous session.""" + connection = self._connection + self._connection = None + channels = tuple(self._channels.values()) + for channel in channels: + channel._invalidate_authentication() + if connection is None: + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + asyncio.run(self._close_invalidated(connection)) + else: + task = loop.create_task(self._close_invalidated(connection)) + self._auth_cleanup_tasks.add(task) + task.add_done_callback(self._auth_cleanup_tasks.discard) + + async def _close_invalidated( + self, + connection: CentrifugeConnection, + ) -> None: + try: + await connection.disconnect() + except Exception: + _LOGGER.exception("Volcano realtime disconnect failed after auth change") diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 0fda2347..5081fcb6 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -665,6 +665,16 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: "auth_convert_anonymous", AuthResponse(200, {"user": _user_payload()}), ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="converted-access", + refresh_token="converted-refresh", + ), + ), + ) for operation in ( "auth_resend_confirmation", "auth_forgot_password", @@ -710,6 +720,8 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: assert anonymous is not None assert anonymous.access_token == "anonymous-access" + assert client.current_session is not None + assert client.current_session.access_token == "converted-access" assert user_before_reset == converted assert all(result == MessageResult(message="Done") for result in messages) assert email_change == EmailChangeResult( @@ -721,7 +733,7 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: assert confirm_change == MessageResult(message="Done") assert cancel_change == MessageResult(message="Done") assert password_reset == MessageResult(message="Done") - assert client.current_session is None + assert client.current_session is not None assert transport.calls[:2] == [ ( "auth_signup_anonymous", @@ -769,6 +781,25 @@ def test_confirm_email_refreshes_current_user_and_notifies_listeners() -> None: assert observations[-1] is client.current_user +def test_confirm_email_preserves_success_when_user_refresh_fails() -> None: + transport = AuthTransport() + transport.queue("auth_confirm_email", AuthResponse(200, {"message": "Done"})) + transport.queue( + "auth_get_user", + AuthResponse(503, {"error": "Temporarily unavailable"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + + result = client.auth.confirm_email(token="confirmation-token") + + assert result == MessageResult(message="Done") + + def test_hosted_and_oauth_authorization_urls_bind_caller_state( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -949,6 +980,16 @@ def test_provider_and_device_session_flows_return_public_values() -> None: "total_pages": 1, }, ), + AuthResponse( + 200, + { + "sessions": [], + "total": 1, + "page": 2, + "limit": 20, + "total_pages": 2, + }, + ), ) transport.queue("auth_delete_my_session", AuthResponse(204)) transport.queue("auth_delete_all_my_sessions", AuthResponse(204)) @@ -968,6 +1009,7 @@ def test_provider_and_device_session_flows_return_public_values() -> None: endpoint="/user", ) sessions = client.auth.get_sessions(page=1, limit=20) + client.auth.get_sessions(page=2, limit=20) client.auth.delete_all_other_sessions() client.auth.delete_session(session_id="3cd3e058-e3ff-42a5-ae4d-650ef9b45746") @@ -1014,3 +1056,49 @@ def test_delete_session_rejects_a_malformed_identifier() -> None: with pytest.raises(ValidationError, match="session_id must be a valid UUID"): client.auth.delete_session(session_id="not-a-uuid") + + +def test_replacing_auth_invalidates_cached_current_device_ids() -> None: + previous_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" + transport = AuthTransport() + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "sessions": [ + { + "id": previous_id, + "user_id": "user-123", + "provider": "email", + "expires_at": "2026-08-29T12:00:00Z", + "is_active": True, + "is_current": True, + } + ] + }, + ), + ) + transport.queue( + "auth_signin", + AuthResponse( + 200, + _token_payload( + access_token="replacement-access", + refresh_token="replacement-refresh", + ), + ), + ) + transport.queue("auth_delete_my_session", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="previous-access", + refresh_token="previous-refresh", + _transport=transport, + ) + + client.auth.get_sessions() + replacement = client.auth.sign_in(email="user@example.com", password="secret") + client.auth.delete_session(session_id=previous_id) + + assert client.current_session is replacement diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 71a8abcf..1cc165f2 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -239,6 +239,55 @@ def test_realtime_wraps_official_client_without_exposing_it() -> None: assert received == [{"event": "message", "value": "contract"}] +def test_auth_replacement_invalidates_the_connected_realtime_identity() -> None: + transport = AuthTransport() + first = FakeCentrifugeClient() + second = FakeCentrifugeClient() + clients = iter((first, second)) + + def factory(*args: Any, **kwargs: Any) -> FakeCentrifugeClient: + del args, kwargs + return next(clients) + + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=factory, + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + received: list[str] = [] + channel = client.realtime.channel("contract").on( + "message", lambda data: received.append(data["value"]) + ) + await channel.subscribe() + previous_subscription = first.subscription + assert previous_subscription is not None + + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + assert channel._subscription is None + await previous_subscription.emit({"value": "stale"}) + await asyncio.sleep(0) + assert received == [] + + await channel.subscribe() + await second.emit_wire_publication( + "broadcast:contract", + {"value": "fresh"}, + ) + for _ in range(10): + if received: + break + await asyncio.sleep(0) + assert received == ["fresh"] + await client.realtime.disconnect() + + asyncio.run(scenario()) + assert first.calls[-1] == "disconnect" + + def test_realtime_callbacks_run_outside_the_message_processor() -> None: transport = AuthTransport() official = FakeCentrifugeClient() diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index c48fa758..28db8de8 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -87,6 +87,11 @@ def test_constructor_rejects_a_refresh_token_without_an_access_token() -> None: VolcanoClient(anon_key="anon", refresh_token="refresh-token") +def test_constructor_rejects_unknown_authentication_keywords() -> None: + with pytest.raises(TypeError, match="acess_token"): + VolcanoClient(anon_key="anon", acess_token="misspelled") # type: ignore[call-arg] + + def test_auth_state_can_be_replaced_and_cleared() -> None: client = VolcanoClient(anon_key="anon") session = Session( From 8ba1ceb57a1dda392e131e5aa69f861ebb05aeb3 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:26:58 -0400 Subject: [PATCH 10/37] fix: serialize authentication transitions --- docs/authentication.md | 4 + src/volcano_sdk/auth.py | 119 +++++++++++++++++--------- src/volcano_sdk/client.py | 3 +- tests/unit/test_auth.py | 174 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 260 insertions(+), 40 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 32ffd7fa..93be104a 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -102,6 +102,10 @@ converted = client.auth.convert_anonymous( assert converted.id == anonymous_id ``` +Conversion is permanent once the API accepts it. If the follow-up token rotation +fails, the method still returns the converted user and clears the local session; +sign in with the new credentials to continue. + Email workflows are available as explicit operations: ```python diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index c9a6940b..ed54bcb3 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -7,6 +7,7 @@ from datetime import datetime from hmac import compare_digest from secrets import token_urlsafe +from threading import RLock from typing import TYPE_CHECKING, Any, Literal, Protocol, cast from urllib.parse import quote, urlencode from uuid import UUID @@ -230,6 +231,7 @@ class Auth: def __init__(self, client: AuthContext) -> None: """Create an authentication facade backed by a client.""" self._client = client + self._operation_lock = RLock() self._current_device_session_ids: set[str] = set() def sign_up( @@ -279,29 +281,31 @@ def sign_in(self, *, email: str, password: str) -> Session: def sign_out(self) -> None: """Revoke the refresh token and always clear local auth state.""" - session = self._client.current_session - try: - if session is not None and session.refresh_token is not None: - response = invoke( - self._client._transport.auth_logout, - authorization=self._client._anon_token(), - refresh_token=session.refresh_token, - ) - response_payload(response, 204) - finally: - self._clear_auth() + with self._operation_lock: + session = self._client.current_session + try: + if session is not None and session.refresh_token is not None: + response = invoke( + self._client._transport.auth_logout, + authorization=self._client._anon_token(), + refresh_token=session.refresh_token, + ) + response_payload(response, 204) + finally: + self._clear_auth() def get_user(self) -> User: """Load the current user from the API.""" - payload = _mapping( - self._authenticated_payload( - self._client._transport.auth_get_user, - expected_status=200, + with self._operation_lock: + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_get_user, + expected_status=200, + ) ) - ) - user = _user(_mapping(payload.get("user"))) - self._client._set_user(user) - return user + user = _user(_mapping(payload.get("user"))) + self._client._set_user(user) + return user def update_user( self, @@ -310,20 +314,25 @@ def update_user( user_metadata: dict[str, JSONValue] | None = None, ) -> User: """Update the current user's password or metadata.""" - payload = _mapping( - self._authenticated_payload( - self._client._transport.auth_update_user, - expected_status=200, - password=password, - user_metadata=user_metadata, + with self._operation_lock: + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_update_user, + expected_status=200, + password=password, + user_metadata=user_metadata, + ) ) - ) - user = _user(_mapping(payload.get("user"))) - self._client._set_user(user) - return user + user = _user(_mapping(payload.get("user"))) + self._client._set_user(user) + return user def refresh_session(self) -> Session: """Rotate the current refresh token and replace local auth state.""" + with self._operation_lock: + return self._refresh_session() + + def _refresh_session(self) -> Session: session = self._client.current_session if session is None or session.refresh_token is None: self._clear_auth() @@ -388,7 +397,8 @@ def convert_anonymous( ) ) converted_user = _user(_mapping(payload.get("user"))) - self.refresh_session() + with suppress(VolcanoError): + self.refresh_session() return self._client.current_user or converted_user def confirm_email(self, *, token: str) -> MessageResult: @@ -430,7 +440,11 @@ def reset_password(self, *, token: str, new_password: str) -> MessageResult: token=token, new_password=new_password, ) - return _message(_mapping(response_payload(response, 200))) + result = _message(_mapping(response_payload(response, 200))) + if self._client.current_session is not None: + with suppress(VolcanoError): + self.get_user() + return result def request_email_change(self, *, new_email: str) -> EmailChangeResult: """Request a change to the current user's email address.""" @@ -627,6 +641,7 @@ def call_oauth_api( payload = self._authenticated_payload( self._client._transport.call_oauth_provider_api, expected_status=200, + retry_unauthorized=False, provider=_provider(provider), endpoint=endpoint, method=method, @@ -663,15 +678,18 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: def delete_session(self, *, session_id: str) -> None: """Delete one device session.""" try: - UUID(session_id) + normalized_session_id = str(UUID(session_id)) except (TypeError, ValueError, AttributeError) as error: raise ValidationError(_INVALID_SESSION_ID) from error + deletes_current_session = ( + normalized_session_id in self._current_device_session_ids + ) self._authenticated_payload( self._client._transport.auth_delete_my_session, expected_status=204, - session_id=session_id, + session_id=normalized_session_id, ) - if session_id in self._current_device_session_ids: + if deletes_current_session: self._clear_auth() def delete_all_other_sessions(self) -> None: @@ -695,6 +713,23 @@ def _authenticated_payload( operation: Callable[..., TransportResponse], *, expected_status: int, + retry_unauthorized: bool = True, + **kwargs: object, + ) -> object: + with self._operation_lock: + return self._authenticated_payload_locked( + operation, + expected_status=expected_status, + retry_unauthorized=retry_unauthorized, + **kwargs, + ) + + def _authenticated_payload_locked( + self, + operation: Callable[..., TransportResponse], + *, + expected_status: int, + retry_unauthorized: bool, **kwargs: object, ) -> object: try: @@ -706,7 +741,11 @@ def _authenticated_payload( return response_payload(response, expected_status) except AuthenticationError as error: session = self._client.current_session - if error.status != _HTTP_UNAUTHORIZED or session is None: + if ( + error.status != _HTTP_UNAUTHORIZED + or session is None + or not retry_unauthorized + ): raise if session.refresh_token is None: self._clear_auth() @@ -720,9 +759,11 @@ def _authenticated_payload( return response_payload(response, expected_status) def _replace_auth(self, session: Session, user: User) -> None: - self._current_device_session_ids.clear() - self._client._commit_auth(session, user) + with self._operation_lock: + self._current_device_session_ids.clear() + self._client._commit_auth(session, user) def _clear_auth(self) -> None: - self._current_device_session_ids.clear() - self._client._clear_auth() + with self._operation_lock: + self._current_device_session_ids.clear() + self._client._clear_auth() diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index ca958d12..be640731 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -147,7 +147,8 @@ def _subscribe_auth( listener_id = self._next_auth_listener_id self._next_auth_listener_id += 1 self._auth_listeners[listener_id] = listener - self._invoke_auth_listener(listener) + if self._current_session is None or self._current_user is not None: + self._invoke_auth_listener(listener) def unsubscribe() -> None: with self._auth_state_lock: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 5081fcb6..b12471b9 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -649,6 +649,23 @@ def failing(_user: User | None) -> None: assert observed == [("first", user), ("second", user)] +def test_restored_session_listener_waits_for_user_hydration() -> None: + transport = AuthTransport() + transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) + client = VolcanoClient( + anon_key="anon-key", + access_token="restored-access", + _transport=transport, + ) + observations: list[User | None] = [] + + client.auth.on_auth_state_change(observations.append) + assert observations == [] + + user = client.auth.get_user() + assert observations == [user] + + def test_anonymous_and_email_account_flows_return_public_values() -> None: transport = AuthTransport() transport.queue( @@ -697,6 +714,7 @@ def test_anonymous_and_email_account_flows_return_public_values() -> None: }, ), ) + transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) client = VolcanoClient(anon_key="anon-key", _transport=transport) anonymous = client.auth.sign_up_anonymous(user_metadata={"display_name": "Guest"}) @@ -781,6 +799,56 @@ def test_confirm_email_refreshes_current_user_and_notifies_listeners() -> None: assert observations[-1] is client.current_user +def test_password_reset_clears_a_revoked_current_session() -> None: + transport = AuthTransport() + transport.queue("auth_reset_password", AuthResponse(200, {"message": "Done"})) + transport.queue( + "auth_get_user", + AuthResponse(401, {"error": "Access token expired"}), + ) + transport.queue( + "auth_refresh", + AuthResponse(401, {"error": "Refresh token expired"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + + result = client.auth.reset_password(token="recovery-token", new_password="next") + + assert result == MessageResult(message="Done") + assert client.current_session is None + + +def test_convert_anonymous_preserves_success_when_session_refresh_fails() -> None: + transport = AuthTransport() + transport.queue( + "auth_convert_anonymous", + AuthResponse(200, {"user": _user_payload(email="converted@example.com")}), + ) + transport.queue( + "auth_refresh", + AuthResponse(503, {"error": "Temporarily unavailable"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="anonymous-access", + refresh_token="anonymous-refresh", + _transport=transport, + ) + + converted = client.auth.convert_anonymous( + email="converted@example.com", + password="secret", + ) + + assert converted.email == "converted@example.com" + assert client.current_session is None + + def test_confirm_email_preserves_success_when_user_refresh_fails() -> None: transport = AuthTransport() transport.queue("auth_confirm_email", AuthResponse(200, {"message": "Done"})) @@ -1043,9 +1111,115 @@ def test_provider_and_device_session_flows_return_public_values() -> None: limit=20, total_pages=1, ) + + +def test_deleting_current_session_survives_automatic_refresh() -> None: + current_session_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" + transport = AuthTransport() + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "sessions": [ + { + "id": current_session_id, + "user_id": "user-123", + "provider": "email", + "expires_at": "2026-08-29T12:00:00Z", + "is_active": True, + "is_current": True, + } + ], + "total": 1, + "page": 1, + "limit": 20, + "total_pages": 1, + }, + ), + ) + transport.queue( + "auth_delete_my_session", + AuthResponse(401, {"error": "Access token expired"}), + AuthResponse(204), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="rotated-access", + refresh_token="rotated-refresh", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + + client.auth.get_sessions() + client.auth.delete_session(session_id=current_session_id) + assert client.current_session is None +def test_delete_session_normalizes_current_uuid_before_matching() -> None: + current_session_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" + transport = AuthTransport() + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "sessions": [ + { + "id": current_session_id, + "user_id": "user-123", + "provider": "email", + "expires_at": "2026-08-29T12:00:00Z", + "is_active": True, + "is_current": True, + } + ] + }, + ), + ) + transport.queue("auth_delete_my_session", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + + client.auth.get_sessions() + client.auth.delete_session(session_id=f"{{{current_session_id.upper()}}}") + + assert client.current_session is None + assert transport.calls[-1][1]["session_id"] == current_session_id + + +def test_provider_401_preserves_access_only_session() -> None: + transport = AuthTransport() + transport.queue( + "call_oauth_provider_api", + AuthResponse(401, {"error": "Provider is not linked"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + + with pytest.raises(AuthenticationError, match="Provider is not linked"): + client.auth.call_oauth_api(provider="github", endpoint="/user") + + assert client.current_session is not None + assert client.current_session.access_token == "access-token" + + def test_delete_session_rejects_a_malformed_identifier() -> None: client = VolcanoClient( anon_key="anon-key", From d8683b64f6786d871ea34636289ad5b15a1839f5 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:39:05 -0400 Subject: [PATCH 11/37] fix: distinguish provider authorization failures --- docs/authentication.md | 11 +++++++++-- src/volcano_sdk/auth.py | 31 +++++++++++++++++++++++++------ tests/unit/test_auth.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 8 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 93be104a..2ee3ef7c 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -42,8 +42,11 @@ place them in source control. ## Manage the session Subscribe to auth-state changes when application state must follow the client. -The listener runs immediately with the current user and after committed auth -changes. Call the returned function to unsubscribe. +The listener runs immediately when the client is signed out or already has a +resolved user, then after committed auth changes. For restored tokens without a +loaded profile, the first event waits for `get_user()` or `refresh_session()` so +the client does not report a valid session as signed out. Call the returned +function to unsubscribe. ```python unsubscribe = client.auth.on_auth_state_change( @@ -123,6 +126,10 @@ client.auth.confirm_email_change(token="email-change-token") client.auth.cancel_email_change() ``` +Password reset revokes the reset account's existing sessions. If this client is +using one of them, `reset_password()` clears it before returning; sign in with +the new password to continue. + ## Open hosted auth and OAuth Hosted auth returns a URL and generated state value for your application to diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index ed54bcb3..c3cc0250 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -638,16 +638,35 @@ def call_oauth_api( body: dict[str, JSONValue] | None = None, ) -> JSONValue: """Call a provider API through Volcano's fixed-host proxy.""" - payload = self._authenticated_payload( + with self._operation_lock: + arguments = { + "provider": _provider(provider), + "endpoint": endpoint, + "method": method, + "body": body, + } + try: + payload = self._provider_api_payload(arguments) + except AuthenticationError as error: + session = self._client.current_session + if ( + error.status != _HTTP_UNAUTHORIZED + or session is None + or session.refresh_token is None + or "not linked" in str(error).lower() + ): + raise + self.refresh_session() + payload = self._provider_api_payload(arguments) + return _json_value(payload) + + def _provider_api_payload(self, arguments: Mapping[str, object]) -> object: + return self._authenticated_payload( self._client._transport.call_oauth_provider_api, expected_status=200, retry_unauthorized=False, - provider=_provider(provider), - endpoint=endpoint, - method=method, - body=body, + **arguments, ) - return _json_value(payload) def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: """Return a page of the current user's device sessions.""" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index b12471b9..d428fe06 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1220,6 +1220,37 @@ def test_provider_401_preserves_access_only_session() -> None: assert client.current_session.access_token == "access-token" +def test_provider_api_refreshes_an_expired_session_once() -> None: + transport = AuthTransport() + transport.queue( + "call_oauth_provider_api", + AuthResponse(401, {"error": "Not authenticated"}), + AuthResponse(200, {"login": "octocat"}), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="rotated-access", + refresh_token="rotated-refresh", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="expired-access", + refresh_token="refresh-token", + _transport=transport, + ) + + result = client.auth.call_oauth_api(provider="github", endpoint="/user") + + assert result == {"login": "octocat"} + assert client.current_session is not None + assert client.current_session.access_token == "rotated-access" + + def test_delete_session_rejects_a_malformed_identifier() -> None: client = VolcanoClient( anon_key="anon-key", From ee8c5285288711312d1321f8eaaa9ad1b6800647 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:46:18 -0400 Subject: [PATCH 12/37] fix: preserve authentication invariants --- src/volcano_sdk/_transport.py | 21 +++++++---- src/volcano_sdk/auth.py | 48 ++++++++++++++------------ src/volcano_sdk/models.py | 2 +- tests/unit/test_auth.py | 15 ++++++++ tests/unit/test_generated_transport.py | 23 ++++++++++++ tests/unit/test_state.py | 7 ++++ 6 files changed, 85 insertions(+), 31 deletions(-) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index cd456959..3e372206 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -7,6 +7,7 @@ from io import BytesIO from pathlib import PurePosixPath from typing import TYPE_CHECKING, Any, Literal, Protocol, cast +from urllib.parse import quote from uuid import UUID, uuid4 import httpx @@ -39,7 +40,6 @@ auth_o_auth_authorize, auth_o_auth_exchange, auth_unlink_o_auth_provider, - call_o_auth_provider_api, get_o_auth_provider_token, refresh_o_auth_provider_token, ) @@ -68,7 +68,6 @@ from ._generated.models.auth_signup_anonymous_body import AuthSignupAnonymousBody from ._generated.models.auth_signup_body import AuthSignupBody from ._generated.models.auth_update_user_body import AuthUpdateUserBody -from ._generated.models.call_o_auth_provider_api_body import CallOAuthProviderAPIBody from ._generated.models.database_select_request import DatabaseSelectRequest from ._generated.models.project_lock_lease_request import ProjectLockLeaseRequest from ._generated.models.upload_storage_object_files_body import ( @@ -780,12 +779,20 @@ def call_oauth_provider_api( if body is not None: body_data["body"] = body with self._client(authorization) as client: - response = call_o_auth_provider_api.sync_detailed( - provider, - client=client, - body=CallOAuthProviderAPIBody.from_dict(body_data), + response = client.get_httpx_client().post( + f"/auth/oauth/{quote(provider, safe='')}/call-api", + json=body_data, ) - return self._response(response) + try: + payload = response.json() + except (json.JSONDecodeError, UnicodeDecodeError): + payload = None + return _GeneratedTransportResponse( + status_code=response.status_code, + payload=payload, + content=response.content, + headers=dict(response.headers), + ) def auth_get_my_sessions( self, diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index c3cc0250..30287a18 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -467,14 +467,15 @@ def request_email_change(self, *, new_email: str) -> EmailChangeResult: def confirm_email_change(self, *, token: str) -> MessageResult: """Confirm a pending email change.""" - payload = self._authenticated_payload( - self._client._transport.auth_confirm_email_change, - expected_status=200, - email_change_token=token, - ) - response = _mapping(payload) - self._client._set_user(_user(_mapping(response.get("user")))) - return _message(response) + with self._operation_lock: + payload = self._authenticated_payload( + self._client._transport.auth_confirm_email_change, + expected_status=200, + email_change_token=token, + ) + response = _mapping(payload) + self._client._set_user(_user(_mapping(response.get("user")))) + return _message(response) def cancel_email_change(self) -> MessageResult: """Cancel the current user's pending email change.""" @@ -536,7 +537,7 @@ def exchange_oauth_code( expected_state: str, ) -> Session: """Validate caller state and exchange an OAuth code for a session.""" - if not compare_digest(state, expected_state): + if not compare_digest(state.encode(), expected_state.encode()): raise ValidationError(_INVALID_OAUTH_STATE) response = invoke( self._client._transport.auth_oauth_exchange, @@ -696,20 +697,21 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: def delete_session(self, *, session_id: str) -> None: """Delete one device session.""" - try: - normalized_session_id = str(UUID(session_id)) - except (TypeError, ValueError, AttributeError) as error: - raise ValidationError(_INVALID_SESSION_ID) from error - deletes_current_session = ( - normalized_session_id in self._current_device_session_ids - ) - self._authenticated_payload( - self._client._transport.auth_delete_my_session, - expected_status=204, - session_id=normalized_session_id, - ) - if deletes_current_session: - self._clear_auth() + with self._operation_lock: + try: + normalized_session_id = str(UUID(session_id)) + except (TypeError, ValueError, AttributeError) as error: + raise ValidationError(_INVALID_SESSION_ID) from error + deletes_current_session = ( + normalized_session_id in self._current_device_session_ids + ) + self._authenticated_payload( + self._client._transport.auth_delete_my_session, + expected_status=204, + session_id=normalized_session_id, + ) + if deletes_current_session: + self._clear_auth() def delete_all_other_sessions(self) -> None: """Delete every device session except the current one.""" diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 58610f31..d66e3030 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -38,8 +38,8 @@ class Session: access_token: str = field(repr=False) refresh_token: str | None = field(default=None, repr=False) - expires_in: int | None = None user_id: str | None = None + expires_in: int | None = None @dataclass(frozen=True, slots=True) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index d428fe06..de97e67d 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -998,6 +998,21 @@ def test_oauth_exchange_validates_state_before_committing() -> None: ] +def test_oauth_exchange_rejects_non_ascii_state_without_calling_transport() -> None: + transport = AuthTransport() + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + with pytest.raises(ValidationError, match="OAuth state does not match"): + client.auth.exchange_oauth_code( + code="oauth-code", + redirect_url="https://app.example/callback", + state="café", + expected_state="cafe", + ) + + assert transport.calls == [] + + def test_provider_and_device_session_flows_return_public_values() -> None: transport = AuthTransport() transport.queue("auth_unlink_oauth_provider", AuthResponse(204)) diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index db9dc0b9..66026ffe 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -421,6 +421,29 @@ def test_generated_transport_calls_oauth_operations() -> None: } +def test_generated_transport_preserves_provider_api_array_responses() -> None: + requests: list[httpx.Request] = [] + + def handle(request: httpx.Request) -> httpx.Response: + requests.append(request) + return httpx.Response(200, json=[{"id": 1}, {"id": 2}]) + + transport = GeneratedTransport( + api_url="https://api.test.volcano.dev", + httpx_transport=httpx.MockTransport(handle), + ) + + response = transport.call_oauth_provider_api( + authorization="access-token", + provider="github", + endpoint="/user/repos", + ) + + assert response.payload == [{"id": 1}, {"id": 2}] + assert len(requests) == 1 + assert requests[0].headers["authorization"] == "Bearer access-token" + + def test_generated_transport_calls_device_session_operations() -> None: transport, requests = _recording_transport() diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 28db8de8..5a812300 100644 --- a/tests/unit/test_state.py +++ b/tests/unit/test_state.py @@ -79,6 +79,13 @@ def test_constructor_accepts_an_access_and_refresh_token() -> None: assert client.current_user is None +def test_session_preserves_the_original_positional_argument_order() -> None: + session = Session("access-token", "refresh-token", "user-123") + + assert session.user_id == "user-123" + assert session.expires_in is None + + def test_constructor_rejects_a_refresh_token_without_an_access_token() -> None: with pytest.raises( ValueError, From 7cdeda3c75b74d604d35291f79c7d8f65216a50f Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:59:52 -0400 Subject: [PATCH 13/37] fix: serialize authentication state transitions --- src/volcano_sdk/auth.py | 77 +++++++------ src/volcano_sdk/client.py | 14 ++- src/volcano_sdk/realtime.py | 25 +++-- tests/unit/test_auth.py | 210 ++++++++++++++++++++++++++++++++++++ tests/unit/test_realtime.py | 50 +++++++++ 5 files changed, 328 insertions(+), 48 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 30287a18..1b6969ef 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -387,19 +387,20 @@ def convert_anonymous( user_metadata: dict[str, JSONValue] | None = None, ) -> User: """Convert the current anonymous user to an email account.""" - payload = _mapping( - self._authenticated_payload( - self._client._transport.auth_convert_anonymous, - expected_status=200, - email=email, - password=password, - user_metadata=user_metadata, + with self._operation_lock: + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_convert_anonymous, + expected_status=200, + email=email, + password=password, + user_metadata=user_metadata, + ) ) - ) - converted_user = _user(_mapping(payload.get("user"))) - with suppress(VolcanoError): - self.refresh_session() - return self._client.current_user or converted_user + converted_user = _user(_mapping(payload.get("user"))) + with suppress(VolcanoError): + self.refresh_session() + return self._client.current_user or converted_user def confirm_email(self, *, token: str) -> MessageResult: """Confirm an email address with its one-time token.""" @@ -671,29 +672,30 @@ def _provider_api_payload(self, arguments: Mapping[str, object]) -> object: def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: """Return a page of the current user's device sessions.""" - payload = _mapping( - self._authenticated_payload( - self._client._transport.auth_get_my_sessions, - expected_status=200, - page=page, - limit=limit, + with self._operation_lock: + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_get_my_sessions, + expected_status=200, + page=page, + limit=limit, + ) + ) + sessions_value = payload.get("sessions", payload.get("data")) + if not isinstance(sessions_value, list): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + sessions = cast("list[object]", sessions_value) + mapped_sessions = tuple(_auth_session(_mapping(item)) for item in sessions) + self._current_device_session_ids.update( + session.id for session in mapped_sessions if session.is_current + ) + return SessionPage( + sessions=mapped_sessions, + total=_optional_int(payload.get("total")), + page=_optional_int(payload.get("page")), + limit=_optional_int(payload.get("limit")), + total_pages=_optional_int(payload.get("total_pages")), ) - ) - sessions_value = payload.get("sessions", payload.get("data")) - if not isinstance(sessions_value, list): - raise AuthenticationError(_INVALID_AUTH_RESPONSE) - sessions = cast("list[object]", sessions_value) - mapped_sessions = tuple(_auth_session(_mapping(item)) for item in sessions) - self._current_device_session_ids.update( - session.id for session in mapped_sessions if session.is_current - ) - return SessionPage( - sessions=mapped_sessions, - total=_optional_int(payload.get("total")), - page=_optional_int(payload.get("page")), - limit=_optional_int(payload.get("limit")), - total_pages=_optional_int(payload.get("total_pages")), - ) def delete_session(self, *, session_id: str) -> None: """Delete one device session.""" @@ -777,7 +779,12 @@ def _authenticated_payload_locked( authorization=self._client._session_token(), **kwargs, ) - return response_payload(response, expected_status) + try: + return response_payload(response, expected_status) + except AuthenticationError as error: + if error.status == _HTTP_UNAUTHORIZED: + self._clear_auth() + raise def _replace_auth(self, session: Session, user: User) -> None: with self._operation_lock: diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index be640731..0491871c 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -147,8 +147,12 @@ def _subscribe_auth( listener_id = self._next_auth_listener_id self._next_auth_listener_id += 1 self._auth_listeners[listener_id] = listener - if self._current_session is None or self._current_user is not None: - self._invoke_auth_listener(listener) + notify_immediately = ( + self._current_session is None or self._current_user is not None + ) + current_user = self._current_user + if notify_immediately: + self._invoke_auth_listener(listener, current_user) def unsubscribe() -> None: with self._auth_state_lock: @@ -157,16 +161,18 @@ def unsubscribe() -> None: return unsubscribe def _notify_auth_listeners(self) -> None: + current_user = self._current_user for listener in tuple(self._auth_listeners.values()): - self._invoke_auth_listener(listener) + self._invoke_auth_listener(listener, current_user) def _invoke_auth_listener( self, listener: Callable[[User | None], None], + current_user: User | None, ) -> None: completed = False with suppress(Exception): - listener(self._current_user) + listener(current_user) completed = True if not completed: _LOGGER.error(_AUTH_LISTENER_FAILED) diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 5cee13f1..57058557 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -330,6 +330,7 @@ def __init__( self._client_factory = client_factory self._connection: CentrifugeConnection | None = None self._connection_lock = asyncio.Lock() + self._auth_generation = 0 self._channels: dict[str, Channel] = {} self._auth_cleanup_tasks: set[asyncio.Task[None]] = set() @@ -356,16 +357,21 @@ async def _connect(self) -> CentrifugeConnection: async def _connect_locked(self) -> CentrifugeConnection: if self._connection is not None: return self._connection - connection = _VolcanoCentrifugeConnection( - self._client_factory( - self._address(), - token=self._client_context._session_token(), - get_token=self._token, + while self._connection is None: + generation = self._auth_generation + connection = _VolcanoCentrifugeConnection( + self._client_factory( + self._address(), + token=self._client_context._session_token(), + get_token=self._token, + ) ) - ) - await connection.connect() - self._connection = connection - return connection + await connection.connect() + if generation == self._auth_generation: + self._connection = connection + else: + await self._close_invalidated(connection) + return self._connection async def _subscribe(self, channel: Channel) -> None: async with self._connection_lock: @@ -402,6 +408,7 @@ async def disconnect(self) -> None: def on_auth_change(self) -> None: """Immediately invalidate work authenticated by the previous session.""" + self._auth_generation += 1 connection = self._connection self._connection = None channels = tuple(self._channels.values()) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index de97e67d..a934a6b4 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -2,9 +2,11 @@ from dataclasses import FrozenInstanceError, dataclass, fields from datetime import UTC, datetime +from threading import Event, Thread from typing import TYPE_CHECKING, Any, cast import pytest +from typing_extensions import override from volcano_sdk import ( AuthorizationRequest, @@ -163,6 +165,28 @@ def auth_delete_all_my_sessions(self, **kwargs: Any) -> AuthResponse: return self._invoke("auth_delete_all_my_sessions", kwargs) +class BlockingAuthTransport(AuthTransport): + def __init__(self, operation: str) -> None: + super().__init__() + self._operation = operation + self._blocked = False + self.entered = Event() + self.release = Event() + self.called = Event() + + @override + def _invoke(self, operation: str, kwargs: dict[str, Any]) -> AuthResponse: + response = super()._invoke(operation, kwargs) + self.called.set() + if operation == self._operation and not self._blocked: + self._blocked = True + self.entered.set() + if not self.release.wait(timeout=1): + message = "timed out waiting to release auth transport" + raise AssertionError(message) + return response + + def test_public_auth_values_are_frozen_and_slotted() -> None: user = User(id="user-123", email="user@example.com") session = Session(access_token="access-token") @@ -582,6 +606,37 @@ def test_rejected_access_only_session_clears_local_auth() -> None: assert client.current_user is None +def test_rejected_post_refresh_retry_clears_rotated_auth() -> None: + transport = AuthTransport() + transport.queue( + "auth_get_user", + AuthResponse(401, {"error": "Access token expired"}), + AuthResponse(401, {"error": "Session revoked"}), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="rotated-access", + refresh_token="rotated-refresh", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="expired-access", + refresh_token="refresh-token", + _transport=transport, + ) + + with pytest.raises(AuthenticationError, match="Session revoked"): + client.auth.get_user() + + assert client.current_session is None + assert client.current_user is None + + def test_sign_out_always_clears_local_auth() -> None: transport = AuthTransport() transport.queue("auth_logout", AuthResponse(204)) @@ -649,6 +704,39 @@ def failing(_user: User | None) -> None: assert observed == [("first", user), ("second", user)] +def test_immediate_auth_listener_does_not_invert_auth_state_locks() -> None: + transport = BlockingAuthTransport("auth_get_user") + transport.queue( + "auth_get_user", + AuthResponse(200, {"user": _user_payload()}), + AuthResponse(200, {"user": _user_payload()}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + client._set_user(User(id="user-123", email="user@example.com")) + loading = Thread(target=client.auth.get_user) + loading.start() + assert transport.entered.wait(timeout=1) + callback_entered = Event() + + def listener(_user: User | None) -> None: + callback_entered.set() + client.auth.get_user() + + subscribing = Thread(target=lambda: client.auth.on_auth_state_change(listener)) + subscribing.start() + assert callback_entered.wait(timeout=1) + transport.release.set() + loading.join(timeout=1) + subscribing.join(timeout=1) + + assert not loading.is_alive() + assert not subscribing.is_alive() + + def test_restored_session_listener_waits_for_user_hydration() -> None: transport = AuthTransport() transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) @@ -849,6 +937,69 @@ def test_convert_anonymous_preserves_success_when_session_refresh_fails() -> Non assert client.current_session is None +def test_convert_anonymous_serializes_replacement_auth() -> None: + transport = BlockingAuthTransport("auth_convert_anonymous") + transport.queue( + "auth_convert_anonymous", + AuthResponse(200, {"user": _user_payload(email="converted@example.com")}), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="converted-access", + refresh_token="converted-refresh", + email="converted@example.com", + ), + ), + ) + transport.queue( + "auth_signin", + AuthResponse( + 200, + _token_payload( + access_token="replacement-access", + refresh_token="replacement-refresh", + email="replacement@example.com", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="anonymous-access", + refresh_token="anonymous-refresh", + _transport=transport, + ) + converted: list[User] = [] + conversion = Thread( + target=lambda: converted.append( + client.auth.convert_anonymous( + email="converted@example.com", + password="secret", + ) + ) + ) + conversion.start() + assert transport.entered.wait(timeout=1) + transport.called.clear() + replacement = Thread( + target=lambda: client.auth.sign_in( + email="replacement@example.com", + password="secret", + ) + ) + replacement.start() + assert transport.called.wait(timeout=1) + transport.release.set() + conversion.join(timeout=1) + replacement.join(timeout=1) + + assert converted[0].email == "converted@example.com" + assert client.current_user is not None + assert client.current_user.email == "replacement@example.com" + + def test_confirm_email_preserves_success_when_user_refresh_fails() -> None: transport = AuthTransport() transport.queue("auth_confirm_email", AuthResponse(200, {"message": "Done"})) @@ -1322,3 +1473,62 @@ def test_replacing_auth_invalidates_cached_current_device_ids() -> None: client.auth.delete_session(session_id=previous_id) assert client.current_session is replacement + + +def test_session_listing_serializes_replacement_auth() -> None: + previous_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" + transport = BlockingAuthTransport("auth_get_my_sessions") + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "sessions": [ + { + "id": previous_id, + "user_id": "user-123", + "provider": "email", + "expires_at": "2026-08-29T12:00:00Z", + "is_active": True, + "is_current": True, + } + ] + }, + ), + ) + transport.queue( + "auth_signin", + AuthResponse( + 200, + _token_payload( + access_token="replacement-access", + refresh_token="replacement-refresh", + ), + ), + ) + transport.queue("auth_delete_my_session", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="previous-access", + refresh_token="previous-refresh", + _transport=transport, + ) + listing = Thread(target=client.auth.get_sessions) + listing.start() + assert transport.entered.wait(timeout=1) + transport.called.clear() + replacement = Thread( + target=lambda: client.auth.sign_in( + email="user@example.com", + password="secret", + ) + ) + replacement.start() + assert transport.called.wait(timeout=1) + transport.release.set() + listing.join(timeout=1) + replacement.join(timeout=1) + client.auth.delete_session(session_id=previous_id) + + assert client.current_session is not None + assert client.current_session.access_token == "replacement-access" diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 1cc165f2..ffb31963 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -400,6 +400,56 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_auth_change_replaces_an_in_flight_realtime_connection( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + first = FakeCentrifugeClient() + second = FakeCentrifugeClient() + entered = asyncio.Event() + release = asyncio.Event() + clients = iter((first, second)) + tokens: list[str] = [] + + async def connect() -> None: + entered.set() + await release.wait() + first.calls.append("connect") + + monkeypatch.setattr(first, "connect", connect) + + def factory( + address: str, + *, + token: str, + get_token: Callable[[], Awaitable[str]], + ) -> FakeCentrifugeClient: + del address, get_token + tokens.append(token) + return next(clients) + + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=factory, + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + connecting = asyncio.create_task(client.realtime._connect()) + await entered.wait() + transport.access_token = "access-2" + client.auth.sign_in(email="user@example.com", password="secret") + release.set() + assert await connecting is not first + await client.realtime.disconnect() + + asyncio.run(scenario()) + assert tokens == ["access-1", "access-2"] + assert first.calls == ["connect", "disconnect"] + assert second.calls == ["connect", "disconnect"] + + @dataclass class FailingFirstCallback: received: list[str] From c1c0aee683f4642bea629e4b4b5980dc25f1b662 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:22:57 -0400 Subject: [PATCH 14/37] fix: enforce authentication ownership boundaries --- src/volcano_sdk/auth.py | 24 ++++++++--- src/volcano_sdk/models.py | 45 +++++++++++++++++-- src/volcano_sdk/realtime.py | 12 +++++- tests/unit/test_auth.py | 86 +++++++++++++++++++++++++++++++++++++ tests/unit/test_realtime.py | 37 ++++++++++++++++ 5 files changed, 193 insertions(+), 11 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 1b6969ef..efe37443 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -251,9 +251,10 @@ def sign_up( user_metadata=user_metadata, ) payload = _mapping(response_payload(response, 201)) - confirmation_required = payload.get("confirmation_required") is True - message_value = payload.get("message") - message = message_value if isinstance(message_value, str) else "" + confirmation_required = payload.get("confirmation_required") + message = payload.get("message") + if not isinstance(confirmation_required, bool) or not isinstance(message, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) if sign_in and not confirmation_required: session = self.sign_in(email=email, password=password) return SignUpResult( @@ -334,7 +335,9 @@ def refresh_session(self) -> Session: def _refresh_session(self) -> Session: session = self._client.current_session - if session is None or session.refresh_token is None: + if session is None: + raise AuthenticationError(_MISSING_AUTH_STATE) + if session.refresh_token is None: self._clear_auth() raise AuthenticationError(_MISSING_AUTH_STATE) @@ -350,7 +353,7 @@ def _refresh_session(self) -> Session: ) if refreshed.refresh_token is None: raise AuthenticationError(_INVALID_AUTH_RESPONSE) - self._replace_auth(refreshed, user) + self._replace_auth(refreshed, user, preserve_device_sessions=True) succeeded = True return refreshed finally: @@ -786,9 +789,16 @@ def _authenticated_payload_locked( self._clear_auth() raise - def _replace_auth(self, session: Session, user: User) -> None: + def _replace_auth( + self, + session: Session, + user: User, + *, + preserve_device_sessions: bool = False, + ) -> None: with self._operation_lock: - self._current_device_session_ids.clear() + if not preserve_device_sessions: + self._current_device_session_ids.clear() self._client._commit_auth(session, user) def _clear_auth(self) -> None: diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index d66e3030..df197932 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -2,18 +2,46 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field +from types import MappingProxyType from typing import TYPE_CHECKING, Literal, TypeAlias if TYPE_CHECKING: from datetime import datetime JSONValue: TypeAlias = ( - str | int | float | bool | list["JSONValue"] | dict[str, "JSONValue"] | None + str + | int + | float + | bool + | list["JSONValue"] + | tuple["JSONValue", ...] + | dict[str, "JSONValue"] + | Mapping[str, "JSONValue"] + | None ) OAuthProviderName: TypeAlias = Literal["google", "github", "microsoft", "apple"] +def _freeze_json(value: JSONValue) -> JSONValue: + if isinstance(value, Mapping): + return MappingProxyType( + {key: _freeze_json(item) for key, item in value.items()} + ) + if isinstance(value, (list, tuple)): + return tuple(_freeze_json(item) for item in value) + return value + + +def _freeze_metadata( + value: Mapping[str, JSONValue] | None, +) -> Mapping[str, JSONValue] | None: + if value is None: + return None + return MappingProxyType({key: _freeze_json(item) for key, item in value.items()}) + + @dataclass(frozen=True, slots=True) class User: """Authenticated Volcano user.""" @@ -22,8 +50,14 @@ class User: email: str project_id: str | None = None email_confirmed: bool | None = None - user_metadata: dict[str, JSONValue] | None = field(default=None, repr=False) - app_metadata: dict[str, JSONValue] | None = field(default=None, repr=False) + user_metadata: Mapping[str, JSONValue] | None = field( + default=None, + repr=False, + ) + app_metadata: Mapping[str, JSONValue] | None = field( + default=None, + repr=False, + ) avatar_url: str | None = None status: str | None = None banned_until: datetime | None = None @@ -31,6 +65,11 @@ class User: created_at: datetime | None = None updated_at: datetime | None = None + def __post_init__(self) -> None: + """Defensively freeze nested metadata owned by this value.""" + object.__setattr__(self, "user_metadata", _freeze_metadata(self.user_metadata)) + object.__setattr__(self, "app_metadata", _freeze_metadata(self.app_metadata)) + @dataclass(frozen=True, slots=True) class Session: diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 57058557..798f2aaf 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -333,6 +333,7 @@ def __init__( self._auth_generation = 0 self._channels: dict[str, Channel] = {} self._auth_cleanup_tasks: set[asyncio.Task[None]] = set() + self._in_flight_publishes: set[asyncio.Task[Any]] = set() def channel(self, name: str) -> Channel: """Return a stable channel facade for a broadcast name.""" @@ -387,7 +388,14 @@ async def _publish(self, channel: Channel, data: Any) -> None: async with self._connection_lock: if channel._subscription is None: raise RuntimeError(CHANNEL_NOT_SUBSCRIBED) - await channel._subscription.publish(data) + task = asyncio.current_task() + if task is not None: + self._in_flight_publishes.add(task) + try: + await channel._subscription.publish(data) + finally: + if task is not None: + self._in_flight_publishes.discard(task) async def _unsubscribe(self, channel: Channel) -> None: async with self._connection_lock: @@ -412,6 +420,8 @@ def on_auth_change(self) -> None: connection = self._connection self._connection = None channels = tuple(self._channels.values()) + for task in tuple(self._in_flight_publishes): + task.cancel() for channel in channels: channel._invalidate_authentication() if connection is None: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index a934a6b4..bd21256f 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -242,6 +242,17 @@ def test_public_auth_values_are_frozen_and_slotted() -> None: setattr(value, first_field, None) +def test_user_metadata_is_defensively_deeply_frozen() -> None: + metadata: dict[str, Any] = {"nested": [{"value": "kept"}]} + user = User(id="user-123", email="user@example.com", user_metadata=metadata) + metadata["nested"][0]["value"] = "changed" + + nested = cast("Any", user.user_metadata)["nested"] + assert nested[0]["value"] == "kept" + with pytest.raises(TypeError): + nested[0]["value"] = "changed" + + def test_public_auth_value_annotations_do_not_expose_generated_models() -> None: public_values = ( User, @@ -332,6 +343,23 @@ def test_sign_up_returns_a_sessionless_result() -> None: ] +@pytest.mark.parametrize( + "payload", + [ + {"message": "Check your email"}, + {"confirmation_required": True}, + {"confirmation_required": "yes", "message": "Check your email"}, + ], +) +def test_sign_up_rejects_malformed_acknowledgements(payload: dict[str, Any]) -> None: + transport = AuthTransport() + transport.queue("auth_signup", AuthResponse(201, payload)) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + with pytest.raises(AuthenticationError, match="missing required fields"): + client.auth.sign_up(email="user@example.com", password="secret") + + def test_sign_up_can_sign_in_immediately_when_confirmation_is_not_required() -> None: transport = AuthTransport() transport.queue( @@ -637,6 +665,18 @@ def test_rejected_post_refresh_retry_clears_rotated_auth() -> None: assert client.current_user is None +def test_refresh_without_local_auth_does_not_emit_another_signed_out_event() -> None: + client = VolcanoClient(anon_key="anon-key") + observations: list[User | None] = [] + client.auth.on_auth_state_change(observations.append) + observations.clear() + + with pytest.raises(AuthenticationError, match="No refresh token available"): + client.auth.refresh_session() + + assert observations == [] + + def test_sign_out_always_clears_local_auth() -> None: transport = AuthTransport() transport.queue("auth_logout", AuthResponse(204)) @@ -1532,3 +1572,49 @@ def test_session_listing_serializes_replacement_auth() -> None: assert client.current_session is not None assert client.current_session.access_token == "replacement-access" + + +def test_refresh_preserves_cached_current_device_identity() -> None: + current_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" + transport = AuthTransport() + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "sessions": [ + { + "id": current_id, + "user_id": "user-123", + "provider": "email", + "expires_at": "2026-08-29T12:00:00Z", + "is_active": True, + "is_current": True, + } + ] + }, + ), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="rotated-access", + refresh_token="rotated-refresh", + ), + ), + ) + transport.queue("auth_delete_my_session", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + + client.auth.get_sessions() + client.auth.refresh_session() + client.auth.delete_session(session_id=current_id) + + assert client.current_session is None diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index ffb31963..57d456e6 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -450,6 +450,43 @@ async def scenario() -> None: assert second.calls == ["connect", "disconnect"] +def test_auth_change_cancels_an_in_flight_realtime_publish( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + entered = asyncio.Event() + release = asyncio.Event() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + channel = client.realtime.channel("contract") + await channel.subscribe() + assert official.subscription is not None + + async def publish(data: Any) -> None: + del data + entered.set() + await release.wait() + + monkeypatch.setattr(official.subscription, "publish", publish) + publishing = asyncio.create_task(channel.send({"value": "old-session"})) + await entered.wait() + transport.access_token = "access-2" + client.auth.sign_in(email="user@example.com", password="secret") + with pytest.raises(asyncio.CancelledError): + await publishing + await asyncio.sleep(0) + await client.realtime.disconnect() + + asyncio.run(scenario()) + + @dataclass class FailingFirstCallback: received: list[str] From cdbfac345da32f56f523f9b839516d00d80b2bbe Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:42:00 -0400 Subject: [PATCH 15/37] fix: preserve authenticated operation guarantees --- features/steps/sdk_contract_steps.py | 15 ++++++++ src/volcano_sdk/_transport.py | 38 ++++++++++++------- src/volcano_sdk/auth.py | 21 ++++++----- src/volcano_sdk/realtime.py | 34 +++++++++++++---- tests/unit/test_generated_transport.py | 19 ++++++++++ tests/unit/test_realtime.py | 51 ++++++++++++++++++++++++++ 6 files changed, 149 insertions(+), 29 deletions(-) diff --git a/features/steps/sdk_contract_steps.py b/features/steps/sdk_contract_steps.py index fa354d99..27e080b7 100644 --- a/features/steps/sdk_contract_steps.py +++ b/features/steps/sdk_contract_steps.py @@ -301,9 +301,24 @@ def deleted_session_is_absent(context: Any) -> None: @when("the client deletes all current-user sessions") def delete_all_current_user_sessions(context: Any) -> None: world = _world(context) + verification_client = type(world.client)( + api_url=world.fixture["api_url"], + anon_key=world.fixture["anon_key"], + ) + verification_client.auth.sign_in( + email=world.fixture["user_email"], + password=world.fixture["user_password"], + ) def operation() -> None: world.client.auth.delete_all_other_sessions() + try: + verification_client.auth.get_user() + except CONTRACT_EXCEPTIONS: + pass + else: + message = "bulk session deletion left another session active" + raise AssertionError(message) world.client.auth.sign_out() world.record(operation) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index 3e372206..c22c8892 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from collections.abc import Mapping from dataclasses import dataclass from io import BytesIO from pathlib import PurePosixPath @@ -86,10 +87,19 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable from .models import JSONValue, OAuthProviderName + +def _mutable_json(value: JSONValue) -> JSONValue: + if isinstance(value, Mapping): + return {key: _mutable_json(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_mutable_json(item) for item in value] + return value + + HTTP_NOT_FOUND = 404 HTTP_CONFLICT = 409 HTTP_RATE_LIMITED = 429 @@ -135,7 +145,7 @@ def auth_signup( authorization: str, email: str, password: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: ... def auth_signin( @@ -167,14 +177,14 @@ def auth_update_user( *, authorization: str, password: str | None = None, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: ... def auth_signup_anonymous( self, *, authorization: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: ... def auth_convert_anonymous( @@ -183,7 +193,7 @@ def auth_convert_anonymous( authorization: str, email: str, password: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: ... def auth_confirm_email( @@ -481,11 +491,11 @@ def auth_signup( authorization: str, email: str, password: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: body_data: dict[str, Any] = {"email": email, "password": password} if user_metadata is not None: - body_data["user_metadata"] = user_metadata + body_data["user_metadata"] = _mutable_json(user_metadata) with self._client(authorization) as client: response = auth_signup.sync_detailed( client=client, @@ -529,13 +539,13 @@ def auth_update_user( *, authorization: str, password: str | None = None, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: body_data: dict[str, Any] = {} if password is not None: body_data["password"] = password if user_metadata is not None: - body_data["user_metadata"] = user_metadata + body_data["user_metadata"] = _mutable_json(user_metadata) with self._client(authorization) as client: response = auth_update_user.sync_detailed( client=client, @@ -547,10 +557,12 @@ def auth_signup_anonymous( self, *, authorization: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: body_data = ( - {"user_metadata": user_metadata} if user_metadata is not None else {} + {"user_metadata": _mutable_json(user_metadata)} + if user_metadata is not None + else {} ) with self._client(authorization) as client: response = auth_signup_anonymous.sync_detailed( @@ -565,11 +577,11 @@ def auth_convert_anonymous( authorization: str, email: str, password: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> TransportResponse: body_data: dict[str, Any] = {"email": email, "password": password} if user_metadata is not None: - body_data["user_metadata"] = user_metadata + body_data["user_metadata"] = _mutable_json(user_metadata) with self._client(authorization) as client: response = auth_convert_anonymous.sync_detailed( client=client, diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index efe37443..86a2dd52 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -239,7 +239,7 @@ def sign_up( *, email: str, password: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, sign_in: bool = False, ) -> SignUpResult: """Create an account and optionally sign in when policy permits.""" @@ -312,7 +312,7 @@ def update_user( self, *, password: str | None = None, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> User: """Update the current user's password or metadata.""" with self._operation_lock: @@ -370,7 +370,7 @@ def on_auth_state_change( def sign_up_anonymous( self, *, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> Session: """Create an anonymous user and replace client-owned auth state.""" response = invoke( @@ -387,7 +387,7 @@ def convert_anonymous( *, email: str, password: str, - user_metadata: dict[str, JSONValue] | None = None, + user_metadata: Mapping[str, JSONValue] | None = None, ) -> User: """Convert the current anonymous user to an email account.""" with self._operation_lock: @@ -413,9 +413,7 @@ def confirm_email(self, *, token: str) -> MessageResult: token=token, ) result = _message(_mapping(response_payload(response, 200))) - if self._client.current_session is not None: - with suppress(VolcanoError): - self.get_user() + self._refresh_user_best_effort() return result def resend_confirmation(self, *, email: str) -> MessageResult: @@ -445,10 +443,15 @@ def reset_password(self, *, token: str, new_password: str) -> MessageResult: new_password=new_password, ) result = _message(_mapping(response_payload(response, 200))) - if self._client.current_session is not None: + self._refresh_user_best_effort() + return result + + def _refresh_user_best_effort(self) -> None: + with self._operation_lock: + if self._client.current_session is None: + return with suppress(VolcanoError): self.get_user() - return result def request_email_change(self, *, new_email: str) -> EmailChangeResult: """Request a change to the current user's email address.""" diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 798f2aaf..3533ba24 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -24,6 +24,13 @@ _LOGGER = logging.getLogger(__name__) +def _current_task() -> asyncio.Task[Any] | None: + try: + return asyncio.current_task() + except RuntimeError: + return None + + class RealtimeContext(Protocol): """Client capabilities required by realtime connections.""" @@ -307,7 +314,10 @@ async def _reset(self) -> None: def _invalidate_authentication(self) -> None: self._auth_generation += 1 self._subscription = None - if self._active_callback_task is not None: + if ( + self._active_callback_task is not None + and self._active_callback_task is not _current_task() + ): self._active_callback_task.cancel() while not self._callback_queue.empty(): self._callback_queue.get_nowait() @@ -376,13 +386,23 @@ async def _connect_locked(self) -> CentrifugeConnection: async def _subscribe(self, channel: Channel) -> None: async with self._connection_lock: - connection = await self._connect_locked() - if channel._subscription is None: - channel._subscription = connection.new_subscription( - channel._name, - events=_ChannelEvents(channel, channel._auth_generation), - ) + await self._subscribe_locked(channel) + + async def _subscribe_locked(self, channel: Channel) -> None: + if channel._subscription is not None: await channel._subscription.subscribe() + return + while channel._subscription is None: + generation = channel._auth_generation + connection = await self._connect_locked() + subscription = connection.new_subscription( + channel._name, + events=_ChannelEvents(channel, generation), + ) + channel._subscription = subscription + await subscription.subscribe() + if generation != channel._auth_generation: + channel._subscription = None async def _publish(self, channel: Channel, data: Any) -> None: async with self._connection_lock: diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 66026ffe..6ce7aef6 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -4,6 +4,7 @@ import httpx +from volcano_sdk import User from volcano_sdk._transport import GeneratedTransport, TransportResponse @@ -256,6 +257,24 @@ def test_generated_transport_calls_session_core_operations() -> None: ] +def test_generated_transport_thaws_frozen_user_metadata() -> None: + transport, requests = _recording_transport() + user = User( + id="user-123", + email="user@example.com", + user_metadata={"nested": [{"value": "kept"}]}, + ) + + transport.auth_update_user( + authorization="access-token", + user_metadata=user.user_metadata, + ) + + assert json.loads(requests[0].content) == { + "user_metadata": {"nested": [{"value": "kept"}]} + } + + def test_generated_transport_calls_account_operations() -> None: transport, requests = _recording_transport() diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 57d456e6..cf56c025 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -487,6 +487,57 @@ async def publish(data: Any) -> None: asyncio.run(scenario()) +def test_auth_change_retries_an_in_flight_realtime_subscription( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + first = FakeCentrifugeClient() + second = FakeCentrifugeClient() + clients = iter((first, second)) + entered = asyncio.Event() + release = asyncio.Event() + subscribe_count = 0 + + async def subscribe(subscription: FakeSubscription) -> None: + nonlocal subscribe_count + subscribe_count += 1 + if subscribe_count == 1: + entered.set() + await release.wait() + subscription.calls.append(("subscribe", None)) + + monkeypatch.setattr(FakeSubscription, "subscribe", subscribe) + + def factory( + address: str, + *, + token: str, + get_token: Callable[[], Awaitable[str]], + ) -> FakeCentrifugeClient: + del address, token, get_token + return next(clients) + + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=factory, + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + channel = client.realtime.channel("contract") + subscribing = asyncio.create_task(channel.subscribe()) + await entered.wait() + transport.access_token = "access-2" + client.auth.sign_in(email="user@example.com", password="secret") + release.set() + await subscribing + assert channel._subscription is second.subscription + await client.realtime.disconnect() + + asyncio.run(scenario()) + + @dataclass class FailingFirstCallback: received: list[str] From 3af124523c66259326041d87aa36d0893c7ff6d4 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:03:57 -0400 Subject: [PATCH 16/37] fix: serialize auth event delivery --- src/volcano_sdk/_transport.py | 2 +- src/volcano_sdk/client.py | 72 +++++++++++++++++++------ src/volcano_sdk/realtime.py | 68 +++++++++++++++++++---- tests/unit/test_auth.py | 25 +++++++++ tests/unit/test_generated_transport.py | 21 ++++++++ tests/unit/test_realtime.py | 74 ++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 25 deletions(-) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index c22c8892..6ce85488 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -789,7 +789,7 @@ def call_oauth_provider_api( ) -> TransportResponse: body_data: dict[str, Any] = {"endpoint": endpoint, "method": method} if body is not None: - body_data["body"] = body + body_data["body"] = _mutable_json(body) with self._client(authorization) as client: response = client.get_httpx_client().post( f"/auth/oauth/{quote(provider, safe='')}/call-api", diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 0491871c..8e3e0504 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections import deque from contextlib import suppress from threading import RLock from typing import TYPE_CHECKING, TypedDict, Unpack, cast @@ -32,6 +33,13 @@ class _AuthBootstrap(TypedDict, total=False): refresh_token: str | None +class _AuthListener: + def __init__(self, callback: Callable[[User | None], None]) -> None: + self.callback = callback + self.pending: deque[User | None] = deque() + self.dispatching = False + + class VolcanoClient: """Volcano SDK entry point.""" @@ -66,7 +74,7 @@ def __init__( ) self._current_user: User | None = None self._auth_state_lock = RLock() - self._auth_listeners: dict[int, Callable[[User | None], None]] = {} + self._auth_listeners: dict[int, _AuthListener] = {} self._next_auth_listener_id = 0 self._transport: Transport = ( cast("Transport", _transport) @@ -118,26 +126,29 @@ def _service_token(self) -> str: def _set_session(self, session: Session) -> None: with self._auth_state_lock: self._current_session = session - self.realtime.on_auth_change() + self.realtime.on_auth_change() def _commit_auth(self, session: Session, user: User) -> None: with self._auth_state_lock: self._current_session = session self._current_user = user - self.realtime.on_auth_change() - self._notify_auth_listeners() + listeners = self._queue_auth_notifications() + self.realtime.on_auth_change() + self._notify_auth_listeners(listeners) def _set_user(self, user: User) -> None: with self._auth_state_lock: self._current_user = user - self._notify_auth_listeners() + listeners = self._queue_auth_notifications() + self._notify_auth_listeners(listeners) def _clear_auth(self) -> None: with self._auth_state_lock: self._current_session = None self._current_user = None - self.realtime.on_auth_change() - self._notify_auth_listeners() + listeners = self._queue_auth_notifications() + self.realtime.on_auth_change() + self._notify_auth_listeners(listeners) def _subscribe_auth( self, @@ -146,13 +157,17 @@ def _subscribe_auth( with self._auth_state_lock: listener_id = self._next_auth_listener_id self._next_auth_listener_id += 1 - self._auth_listeners[listener_id] = listener + registration = _AuthListener(listener) + self._auth_listeners[listener_id] = registration notify_immediately = ( self._current_session is None or self._current_user is not None ) - current_user = self._current_user - if notify_immediately: - self._invoke_auth_listener(listener, current_user) + should_dispatch = notify_immediately and self._queue_auth_listener( + registration, + self._current_user, + ) + if should_dispatch: + self._drain_auth_listener(registration) def unsubscribe() -> None: with self._auth_state_lock: @@ -160,10 +175,37 @@ def unsubscribe() -> None: return unsubscribe - def _notify_auth_listeners(self) -> None: - current_user = self._current_user - for listener in tuple(self._auth_listeners.values()): - self._invoke_auth_listener(listener, current_user) + def _queue_auth_notifications(self) -> tuple[_AuthListener, ...]: + listeners = tuple(self._auth_listeners.values()) + return tuple( + listener + for listener in listeners + if self._queue_auth_listener(listener, self._current_user) + ) + + @staticmethod + def _queue_auth_listener( + listener: _AuthListener, + current_user: User | None, + ) -> bool: + listener.pending.append(current_user) + if listener.dispatching: + return False + listener.dispatching = True + return True + + def _notify_auth_listeners(self, listeners: tuple[_AuthListener, ...]) -> None: + for listener in listeners: + self._drain_auth_listener(listener) + + def _drain_auth_listener(self, listener: _AuthListener) -> None: + while True: + with self._auth_state_lock: + if not listener.pending: + listener.dispatching = False + return + current_user = listener.pending.popleft() + self._invoke_auth_listener(listener.callback, current_user) def _invoke_auth_listener( self, diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 3533ba24..1cc16c6d 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -7,6 +7,7 @@ import inspect import logging from collections.abc import Awaitable, Callable +from threading import Event as ThreadEvent from typing import Any, Protocol, cast from urllib.parse import quote, urlsplit, urlunsplit @@ -31,6 +32,13 @@ def _current_task() -> asyncio.Task[Any] | None: return None +def _running_loop() -> asyncio.AbstractEventLoop | None: + try: + return asyncio.get_running_loop() + except RuntimeError: + return None + + class RealtimeContext(Protocol): """Client capabilities required by realtime connections.""" @@ -323,6 +331,16 @@ def _invalidate_authentication(self) -> None: self._callback_queue.get_nowait() self._callback_queue.task_done() + def _discard_closed_loop_authentication(self) -> None: + self._auth_generation += 1 + self._subscription = None + self._callback_task = None + self._active_callback_task = None + self._callback_stop = None + while not self._callback_queue.empty(): + self._callback_queue.get_nowait() + self._callback_queue.task_done() + class Realtime: """Manage project realtime connections and channels.""" @@ -340,6 +358,7 @@ def __init__( self._client_factory = client_factory self._connection: CentrifugeConnection | None = None self._connection_lock = asyncio.Lock() + self._loop: asyncio.AbstractEventLoop | None = None self._auth_generation = 0 self._channels: dict[str, Channel] = {} self._auth_cleanup_tasks: set[asyncio.Task[None]] = set() @@ -368,6 +387,7 @@ async def _connect(self) -> CentrifugeConnection: async def _connect_locked(self) -> CentrifugeConnection: if self._connection is not None: return self._connection + self._loop = asyncio.get_running_loop() while self._connection is None: generation = self._auth_generation connection = _VolcanoCentrifugeConnection( @@ -433,9 +453,35 @@ async def disconnect(self) -> None: finally: for channel in tuple(self._channels.values()): await channel._reset() + self._loop = None def on_auth_change(self) -> None: """Immediately invalidate work authenticated by the previous session.""" + loop = self._loop + if loop is not None and loop.is_closed(): + self._discard_closed_loop_authentication() + return + if loop is not None and _running_loop() is not loop: + self._schedule_auth_invalidation(loop) + return + self._invalidate_authentication() + + def _schedule_auth_invalidation( + self, + loop: asyncio.AbstractEventLoop, + ) -> None: + completed = ThreadEvent() + loop.call_soon_threadsafe(self._invalidate_and_signal, completed) + if loop.is_running(): + completed.wait() + + def _invalidate_and_signal(self, completed: ThreadEvent) -> None: + try: + self._invalidate_authentication() + finally: + completed.set() + + def _invalidate_authentication(self) -> None: self._auth_generation += 1 connection = self._connection self._connection = None @@ -444,16 +490,20 @@ def on_auth_change(self) -> None: task.cancel() for channel in channels: channel._invalidate_authentication() - if connection is None: + if connection is None or self._loop is None: return - try: - loop = asyncio.get_running_loop() - except RuntimeError: - asyncio.run(self._close_invalidated(connection)) - else: - task = loop.create_task(self._close_invalidated(connection)) - self._auth_cleanup_tasks.add(task) - task.add_done_callback(self._auth_cleanup_tasks.discard) + task = self._loop.create_task(self._close_invalidated(connection)) + self._auth_cleanup_tasks.add(task) + task.add_done_callback(self._auth_cleanup_tasks.discard) + + def _discard_closed_loop_authentication(self) -> None: + self._auth_generation += 1 + self._connection = None + self._loop = None + self._auth_cleanup_tasks.clear() + self._in_flight_publishes.clear() + for channel in tuple(self._channels.values()): + channel._discard_closed_loop_authentication() async def _close_invalidated( self, diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index bd21256f..3300a14d 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -777,6 +777,31 @@ def listener(_user: User | None) -> None: assert not subscribing.is_alive() +def test_auth_listener_subscription_preserves_transition_order() -> None: + client = VolcanoClient(anon_key="anon-key") + user = User(id="user-123", email="user@example.com") + client._set_user(user) + initial_entered = Event() + release_initial = Event() + observations: list[User | None] = [] + + def listener(current_user: User | None) -> None: + if current_user is user: + initial_entered.set() + assert release_initial.wait(timeout=1) + observations.append(current_user) + + subscribing = Thread(target=lambda: client.auth.on_auth_state_change(listener)) + subscribing.start() + assert initial_entered.wait(timeout=1) + client._clear_auth() + release_initial.set() + subscribing.join(timeout=1) + + assert not subscribing.is_alive() + assert observations == [user, None] + + def test_restored_session_listener_waits_for_user_hydration() -> None: transport = AuthTransport() transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 6ce7aef6..4ccfd95b 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -463,6 +463,27 @@ def handle(request: httpx.Request) -> httpx.Response: assert requests[0].headers["authorization"] == "Bearer access-token" +def test_generated_transport_thaws_provider_api_request_metadata() -> None: + transport, requests = _recording_transport() + user = User( + id="user-123", + email="user@example.com", + user_metadata={"nested": [{"enabled": True}]}, + ) + + transport.call_oauth_provider_api( + authorization="access-token", + provider="github", + endpoint="/user", + method="POST", + body={"metadata": user.user_metadata}, + ) + + assert json.loads(requests[0].content)["body"] == { + "metadata": {"nested": [{"enabled": True}]} + } + + def test_generated_transport_calls_device_session_operations() -> None: transport, requests = _recording_transport() diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index cf56c025..b773924b 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -145,6 +145,22 @@ async def emit_wire_publication(self, name: str, data: Any) -> None: await subscription.emit(data) +class LoopBoundFakeCentrifugeClient(FakeCentrifugeClient): + def __init__(self) -> None: + super().__init__() + self.owning_loop: asyncio.AbstractEventLoop | None = None + + @override + async def connect(self) -> None: + self.owning_loop = asyncio.get_running_loop() + await super().connect() + + @override + async def disconnect(self) -> None: + assert asyncio.get_running_loop() is self.owning_loop + await super().disconnect() + + @dataclass(frozen=True) class FakeCentrifugeFactory: client: FakeCentrifugeClient @@ -288,6 +304,64 @@ async def scenario() -> None: assert first.calls[-1] == "disconnect" +def test_worker_thread_auth_change_uses_the_realtime_owning_loop() -> None: + transport = AuthTransport() + first = LoopBoundFakeCentrifugeClient() + second = FakeCentrifugeClient() + clients = iter((first, second)) + + def factory( + address: str, + *, + token: str, + get_token: Callable[[], Awaitable[str]], + ) -> FakeCentrifugeClient: + del address, token, get_token + return next(clients) + + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=factory, + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + channel = client.realtime.channel("contract") + await channel.subscribe() + transport.access_token = "access-2" + await asyncio.to_thread( + client.auth.sign_in, + email="next@example.com", + password="secret", + ) + await asyncio.sleep(0) + assert channel._subscription is None + assert first.calls[-1] == "disconnect" + await channel.subscribe() + await client.realtime.disconnect() + + asyncio.run(scenario()) + + +def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + channel = client.realtime.channel("contract") + asyncio.run(channel.subscribe()) + + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + + assert channel._subscription is None + + def test_realtime_callbacks_run_outside_the_message_processor() -> None: transport = AuthTransport() official = FakeCentrifugeClient() From c8d8f9c18bc41ce8804404d4c6b5db859a71192a Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:15:36 -0400 Subject: [PATCH 17/37] fix: retry invalidated realtime subscriptions --- src/volcano_sdk/realtime.py | 8 +++++++- tests/unit/test_realtime.py | 35 ++++++++++++++++++++++++----------- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 1cc16c6d..0a6adf26 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -420,7 +420,13 @@ async def _subscribe_locked(self, channel: Channel) -> None: events=_ChannelEvents(channel, generation), ) channel._subscription = subscription - await subscription.subscribe() + try: + await subscription.subscribe() + except Exception: + if generation == channel._auth_generation: + raise + channel._subscription = None + continue if generation != channel._auth_generation: channel._subscription = None diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index b773924b..12df4055 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -121,6 +121,25 @@ async def emit(self, data: Any) -> None: ) +def _failing_first_subscribe( + entered: asyncio.Event, + release: asyncio.Event, +) -> Callable[[FakeSubscription], Awaitable[None]]: + attempts = 0 + + async def subscribe(subscription: FakeSubscription) -> None: + nonlocal attempts + attempts += 1 + if attempts == 1: + entered.set() + await release.wait() + message = "old connection closed" + raise RuntimeError(message) + subscription.calls.append(("subscribe", None)) + + return subscribe + + class FakeCentrifugeClient: def __init__(self) -> None: self.calls: list[str] = [] @@ -570,17 +589,11 @@ def test_auth_change_retries_an_in_flight_realtime_subscription( clients = iter((first, second)) entered = asyncio.Event() release = asyncio.Event() - subscribe_count = 0 - - async def subscribe(subscription: FakeSubscription) -> None: - nonlocal subscribe_count - subscribe_count += 1 - if subscribe_count == 1: - entered.set() - await release.wait() - subscription.calls.append(("subscribe", None)) - - monkeypatch.setattr(FakeSubscription, "subscribe", subscribe) + monkeypatch.setattr( + FakeSubscription, + "subscribe", + _failing_first_subscribe(entered, release), + ) def factory( address: str, From 175a4b1255f131e605468cccaeee6889f8102b2d Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:27:01 -0400 Subject: [PATCH 18/37] fix: identify restored device sessions --- src/volcano_sdk/auth.py | 19 +++++++++++++++++++ tests/unit/test_auth.py | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 86a2dd52..44dff7b2 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -2,6 +2,9 @@ from __future__ import annotations +import binascii +import json +from base64 import urlsafe_b64decode from collections.abc import Mapping from contextlib import suppress from datetime import datetime @@ -43,6 +46,20 @@ _SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) +def _token_session_id(access_token: str) -> str | None: + try: + encoded = access_token.split(".")[1] + padding = "=" * (-len(encoded) % 4) + parsed = cast("object", json.loads(urlsafe_b64decode(encoded + padding))) + payload: Mapping[str, object] = ( + cast("Mapping[str, object]", parsed) if isinstance(parsed, Mapping) else {} + ) + value = payload.get("session_id") + return str(UUID(value)) if isinstance(value, str) else None + except (ValueError, IndexError, UnicodeDecodeError, binascii.Error): + return None + + class AuthContext(Protocol): """Client capabilities required by the authentication facade.""" @@ -712,6 +729,8 @@ def delete_session(self, *, session_id: str) -> None: raise ValidationError(_INVALID_SESSION_ID) from error deletes_current_session = ( normalized_session_id in self._current_device_session_ids + or normalized_session_id + == _token_session_id(self._client._session_token()) ) self._authenticated_payload( self._client._transport.auth_delete_my_session, diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 3300a14d..2c4b0b14 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +from base64 import urlsafe_b64encode from dataclasses import FrozenInstanceError, dataclass, fields from datetime import UTC, datetime from threading import Event, Thread @@ -70,6 +72,13 @@ def _token_payload( } +def _access_token_for_session(session_id: str) -> str: + payload = urlsafe_b64encode( + json.dumps({"session_id": session_id}).encode() + ).decode() + return f"header.{payload.rstrip('=')}.signature" + + class AuthTransport: def __init__(self) -> None: self.calls: list[tuple[str, dict[str, Any]]] = [] @@ -1494,6 +1503,24 @@ def test_delete_session_rejects_a_malformed_identifier() -> None: client.auth.delete_session(session_id="not-a-uuid") +def test_direct_current_session_deletion_clears_restored_auth() -> None: + current_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" + transport = AuthTransport() + transport.queue("auth_delete_my_session", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token=_access_token_for_session(current_id), + refresh_token="refresh-token", + _transport=transport, + ) + client._set_user(User(id="user-123", email="user@example.com")) + + client.auth.delete_session(session_id=current_id) + + assert client.current_session is None + assert client.current_user is None + + def test_replacing_auth_invalidates_cached_current_device_ids() -> None: previous_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" transport = AuthTransport() From 3ad6a97a503f99e3be8d8792cc6128f0ba5b96be Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:27:01 -0400 Subject: [PATCH 19/37] chore: require compatible Ruff version --- pyproject.toml | 2 +- uv.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 9d7c3a14..fe20933b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ dev = [ "openapi-python-client==0.29.0", "pyright>=1.1.405", "pytest>=8.3.0", - "ruff>=0.9.0", + "ruff>=0.15.14", "types-python-dateutil>=2.9.0.20250822", ] diff --git a/uv.lock b/uv.lock index ec0ae246..837008a6 100644 --- a/uv.lock +++ b/uv.lock @@ -957,7 +957,7 @@ dev = [ { name = "openapi-python-client", specifier = "==0.29.0" }, { name = "pyright", specifier = ">=1.1.405" }, { name = "pytest", specifier = ">=8.3.0" }, - { name = "ruff", specifier = ">=0.9.0" }, + { name = "ruff", specifier = ">=0.15.14" }, { name = "types-python-dateutil", specifier = ">=2.9.0.20250822" }, ] From ae086d624f5707930ed5b37003e04ca4a914b044 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:42:13 -0400 Subject: [PATCH 20/37] fix: use structured provider errors --- src/volcano_sdk/auth.py | 3 ++- tests/unit/test_auth.py | 11 ++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 44dff7b2..dfc6b3ff 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -39,6 +39,7 @@ _INVALID_AUTH_RESPONSE = "Authentication response is missing required fields" _MISSING_AUTH_STATE = "No refresh token available" _HTTP_UNAUTHORIZED = 401 +_PROVIDER_NOT_LINKED_CODE = "provider_not_linked" _INVALID_OAUTH_STATE = "OAuth state does not match" _INVALID_OAUTH_PROVIDER = "Unsupported OAuth provider" _MISSING_AUTHORIZATION_URL = "Authentication response is missing authorization URL" @@ -678,7 +679,7 @@ def call_oauth_api( error.status != _HTTP_UNAUTHORIZED or session is None or session.refresh_token is None - or "not linked" in str(error).lower() + or error.code == _PROVIDER_NOT_LINKED_CODE ): raise self.refresh_session() diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 2c4b0b14..944de0a1 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1441,21 +1441,26 @@ def test_delete_session_normalizes_current_uuid_before_matching() -> None: assert transport.calls[-1][1]["session_id"] == current_session_id -def test_provider_401_preserves_access_only_session() -> None: +def test_provider_401_uses_structured_code_to_preserve_session() -> None: transport = AuthTransport() transport.queue( "call_oauth_provider_api", - AuthResponse(401, {"error": "Provider is not linked"}), + AuthResponse( + 401, + {"error": "Provider unavailable", "code": "provider_not_linked"}, + ), ) client = VolcanoClient( anon_key="anon-key", access_token="access-token", + refresh_token="refresh-token", _transport=transport, ) - with pytest.raises(AuthenticationError, match="Provider is not linked"): + with pytest.raises(AuthenticationError, match="Provider unavailable") as raised: client.auth.call_oauth_api(provider="github", endpoint="/user") + assert raised.value.code == "provider_not_linked" assert client.current_session is not None assert client.current_session.access_token == "access-token" From b8e15d707e9ef630bf5a3fe82b0873cbc9d8737c Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:50:55 -0400 Subject: [PATCH 21/37] feat: expose session cursor pagination --- docs/authentication.md | 8 +++++ src/volcano_sdk/__init__.py | 2 ++ src/volcano_sdk/_transport.py | 19 ++++++---- src/volcano_sdk/auth.py | 15 ++++++-- src/volcano_sdk/models.py | 15 +++++++- tests/unit/test_auth.py | 48 ++++++++++++++++++++++++++ tests/unit/test_generated_transport.py | 11 ++++-- tests/unit/test_import.py | 1 + 8 files changed, 107 insertions(+), 12 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 2ee3ef7c..5c48e838 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -69,6 +69,14 @@ page = client.auth.get_sessions(page=1, limit=20) for device_session in page.sessions: print(device_session.id, device_session.last_activity_at) +if page.next_cursor: + cursor_page = client.auth.get_sessions( + sort="created_at", + status="active", + cursor=page.next_cursor, + limit=20, + ) + client.auth.delete_session(session_id="session-id") client.auth.delete_all_other_sessions() ``` diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index 69ed1c82..5849d07c 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -22,6 +22,7 @@ OAuthProviderName, OAuthTokenResult, Session, + SessionListOptions, SessionPage, SignUpResult, User, @@ -43,6 +44,7 @@ "RateLimitedError", "ServerError", "Session", + "SessionListOptions", "SessionPage", "SignUpResult", "TransportError", diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index 6ce85488..56a49df9 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -7,7 +7,7 @@ from dataclasses import dataclass from io import BytesIO from pathlib import PurePosixPath -from typing import TYPE_CHECKING, Any, Literal, Protocol, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, Unpack, cast from urllib.parse import quote from uuid import UUID, uuid4 @@ -74,7 +74,7 @@ from ._generated.models.upload_storage_object_files_body import ( UploadStorageObjectFilesBody, ) -from ._generated.types import File +from ._generated.types import UNSET, File from .errors import ( AuthenticationError, ConflictError, @@ -89,7 +89,7 @@ if TYPE_CHECKING: from collections.abc import Callable - from .models import JSONValue, OAuthProviderName + from .models import JSONValue, OAuthProviderName, SessionListOptions def _mutable_json(value: JSONValue) -> JSONValue: @@ -312,8 +312,9 @@ def auth_get_my_sessions( self, *, authorization: str, - page: int = 1, + page: int | None = None, limit: int = 20, + **options: Unpack[SessionListOptions], ) -> TransportResponse: ... def auth_delete_my_session( @@ -810,14 +811,20 @@ def auth_get_my_sessions( self, *, authorization: str, - page: int = 1, + page: int | None = None, limit: int = 20, + **options: Unpack[SessionListOptions], ) -> TransportResponse: with self._client(authorization) as client: response = auth_get_my_sessions.sync_detailed( client=client, - page=page, + page=page if page is not None else UNSET, limit=limit, + sort=options.get("sort", UNSET), + status=options.get("status", UNSET), + cursor=options.get("cursor", UNSET), + ending_before=options.get("ending_before", UNSET), + offset=options.get("offset", UNSET), ) return self._response(response) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index dfc6b3ff..ae845273 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -11,7 +11,7 @@ from hmac import compare_digest from secrets import token_urlsafe from threading import RLock -from typing import TYPE_CHECKING, Any, Literal, Protocol, cast +from typing import TYPE_CHECKING, Any, Literal, Protocol, Unpack, cast from urllib.parse import quote, urlencode from uuid import UUID @@ -26,6 +26,7 @@ OAuthProviderName, OAuthTokenResult, Session, + SessionListOptions, SessionPage, SignUpResult, User, @@ -694,7 +695,13 @@ def _provider_api_payload(self, arguments: Mapping[str, object]) -> object: **arguments, ) - def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: + def get_sessions( + self, + *, + page: int | None = None, + limit: int = 20, + **options: Unpack[SessionListOptions], + ) -> SessionPage: """Return a page of the current user's device sessions.""" with self._operation_lock: payload = _mapping( @@ -703,6 +710,7 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: expected_status=200, page=page, limit=limit, + **options, ) ) sessions_value = payload.get("sessions", payload.get("data")) @@ -719,6 +727,9 @@ def get_sessions(self, *, page: int = 1, limit: int = 20) -> SessionPage: page=_optional_int(payload.get("page")), limit=_optional_int(payload.get("limit")), total_pages=_optional_int(payload.get("total_pages")), + has_more=_optional_bool(payload.get("has_more")), + next_cursor=_optional_text(payload.get("next_cursor")), + prev_cursor=_optional_text(payload.get("prev_cursor")), ) def delete_session(self, *, session_id: str) -> None: diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index df197932..a7c92558 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -5,7 +5,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType -from typing import TYPE_CHECKING, Literal, TypeAlias +from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict if TYPE_CHECKING: from datetime import datetime @@ -24,6 +24,16 @@ OAuthProviderName: TypeAlias = Literal["google", "github", "microsoft", "apple"] +class SessionListOptions(TypedDict, total=False): + """Optional filters and cursor controls for listing device sessions.""" + + sort: Literal["last_activity", "created_at"] + status: Literal["active", "expired"] + cursor: str + ending_before: str + offset: int + + def _freeze_json(value: JSONValue) -> JSONValue: if isinstance(value, Mapping): return MappingProxyType( @@ -161,6 +171,9 @@ class SessionPage: page: int | None = None limit: int | None = None total_pages: int | None = None + has_more: bool | None = None + next_cursor: str | None = None + prev_cursor: str | None = None @dataclass(frozen=True, slots=True) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 944de0a1..e707e48f 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1353,6 +1353,54 @@ def test_provider_and_device_session_flows_return_public_values() -> None: ) +def test_session_listing_exposes_filters_and_cursor_navigation() -> None: + transport = AuthTransport() + transport.queue( + "auth_get_my_sessions", + AuthResponse( + 200, + { + "data": [], + "total": 3, + "limit": 1, + "has_more": True, + "next_cursor": "next", + "prev_cursor": "previous", + }, + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + + page = client.auth.get_sessions( + sort="created_at", + status="active", + cursor="cursor", + offset=1, + limit=1, + ) + + assert page == SessionPage( + total=3, + limit=1, + has_more=True, + next_cursor="next", + prev_cursor="previous", + ) + assert transport.calls[-1][1] == { + "authorization": "access-token", + "page": None, + "limit": 1, + "sort": "created_at", + "status": "active", + "cursor": "cursor", + "offset": 1, + } + + def test_deleting_current_session_survives_automatic_refresh() -> None: current_session_id = "3cd3e058-e3ff-42a5-ae4d-650ef9b45746" transport = AuthTransport() diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 4ccfd95b..fb7b372c 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -489,8 +489,11 @@ def test_generated_transport_calls_device_session_operations() -> None: transport.auth_get_my_sessions( authorization="access-token", - page=2, limit=10, + sort="created_at", + status="active", + cursor="next-page", + offset=20, ) transport.auth_delete_my_session( authorization="access-token", @@ -507,9 +510,11 @@ def test_generated_transport_calls_device_session_operations() -> None: ("DELETE", "/auth/user/sessions"), ] assert dict(requests[0].url.params) == { - "page": "2", "limit": "10", - "sort": "last_activity", + "sort": "created_at", + "status": "active", + "cursor": "next-page", + "offset": "20", } assert [request.headers["authorization"] for request in requests] == [ "Bearer access-token", diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py index 61e9d97e..1ed16a07 100644 --- a/tests/unit/test_import.py +++ b/tests/unit/test_import.py @@ -23,6 +23,7 @@ def test_package_exports_the_public_sdk_contract() -> None: "RateLimitedError", "ServerError", "Session", + "SessionListOptions", "SessionPage", "SignUpResult", "TransportError", From 2db7f0e57825471511a10e02ca8001c60d6d434d Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:02:41 -0400 Subject: [PATCH 22/37] fix: harden authentication concurrency --- src/volcano_sdk/_transport.py | 14 +++-- src/volcano_sdk/auth.py | 46 +++++++++++------ src/volcano_sdk/client.py | 25 ++++++++- src/volcano_sdk/realtime.py | 60 ++++++++++++++-------- tests/unit/test_auth.py | 71 ++++++++++++++++++++++++++ tests/unit/test_generated_transport.py | 17 +++++- tests/unit/test_realtime.py | 69 +++++++++++++++++++++++-- 7 files changed, 256 insertions(+), 46 deletions(-) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index 56a49df9..bfb89257 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -114,6 +114,7 @@ def _mutable_json(value: JSONValue) -> JSONValue: 422: ValidationError, HTTP_RATE_LIMITED: RateLimitedError, } +_INVALID_AUTH_RESPONSE = "Invalid authentication response" class TransportResponse(Protocol): @@ -497,11 +498,14 @@ def auth_signup( body_data: dict[str, Any] = {"email": email, "password": password} if user_metadata is not None: body_data["user_metadata"] = _mutable_json(user_metadata) - with self._client(authorization) as client: - response = auth_signup.sync_detailed( - client=client, - body=AuthSignupBody.from_dict(body_data), - ) + try: + with self._client(authorization) as client: + response = auth_signup.sync_detailed( + client=client, + body=AuthSignupBody.from_dict(body_data), + ) + except (KeyError, TypeError, ValueError): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) from None return self._response(response) def auth_refresh( diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index ae845273..299f6c66 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -5,8 +5,8 @@ import binascii import json from base64 import urlsafe_b64decode -from collections.abc import Mapping -from contextlib import suppress +from collections.abc import Generator, Mapping +from contextlib import contextmanager, suppress from datetime import datetime from hmac import compare_digest from secrets import token_urlsafe @@ -93,6 +93,10 @@ def _subscribe_auth( listener: Callable[[User | None], None], ) -> Callable[[], None]: ... + def _begin_auth_notification_deferral(self) -> None: ... + + def _finish_auth_notification_deferral(self) -> Callable[[], None]: ... + def _mapping(value: object) -> Mapping[str, Any]: if isinstance(value, Mapping): @@ -253,6 +257,17 @@ def __init__(self, client: AuthContext) -> None: self._operation_lock = RLock() self._current_device_session_ids: set[str] = set() + @contextmanager + def _operation(self) -> Generator[None, None, None]: + self._operation_lock.acquire() + self._client._begin_auth_notification_deferral() + try: + yield + finally: + dispatch = self._client._finish_auth_notification_deferral() + self._operation_lock.release() + dispatch() + def sign_up( self, *, @@ -301,7 +316,7 @@ def sign_in(self, *, email: str, password: str) -> Session: def sign_out(self) -> None: """Revoke the refresh token and always clear local auth state.""" - with self._operation_lock: + with self._operation(): session = self._client.current_session try: if session is not None and session.refresh_token is not None: @@ -316,7 +331,7 @@ def sign_out(self) -> None: def get_user(self) -> User: """Load the current user from the API.""" - with self._operation_lock: + with self._operation(): payload = _mapping( self._authenticated_payload( self._client._transport.auth_get_user, @@ -334,7 +349,7 @@ def update_user( user_metadata: Mapping[str, JSONValue] | None = None, ) -> User: """Update the current user's password or metadata.""" - with self._operation_lock: + with self._operation(): payload = _mapping( self._authenticated_payload( self._client._transport.auth_update_user, @@ -349,7 +364,7 @@ def update_user( def refresh_session(self) -> Session: """Rotate the current refresh token and replace local auth state.""" - with self._operation_lock: + with self._operation(): return self._refresh_session() def _refresh_session(self) -> Session: @@ -409,7 +424,7 @@ def convert_anonymous( user_metadata: Mapping[str, JSONValue] | None = None, ) -> User: """Convert the current anonymous user to an email account.""" - with self._operation_lock: + with self._operation(): payload = _mapping( self._authenticated_payload( self._client._transport.auth_convert_anonymous, @@ -466,7 +481,7 @@ def reset_password(self, *, token: str, new_password: str) -> MessageResult: return result def _refresh_user_best_effort(self) -> None: - with self._operation_lock: + with self._operation(): if self._client.current_session is None: return with suppress(VolcanoError): @@ -493,7 +508,7 @@ def request_email_change(self, *, new_email: str) -> EmailChangeResult: def confirm_email_change(self, *, token: str) -> MessageResult: """Confirm a pending email change.""" - with self._operation_lock: + with self._operation(): payload = self._authenticated_payload( self._client._transport.auth_confirm_email_change, expected_status=200, @@ -665,7 +680,7 @@ def call_oauth_api( body: dict[str, JSONValue] | None = None, ) -> JSONValue: """Call a provider API through Volcano's fixed-host proxy.""" - with self._operation_lock: + with self._operation(): arguments = { "provider": _provider(provider), "endpoint": endpoint, @@ -681,6 +696,7 @@ def call_oauth_api( or session is None or session.refresh_token is None or error.code == _PROVIDER_NOT_LINKED_CODE + or (not error.code and "not linked" in str(error).lower()) ): raise self.refresh_session() @@ -703,7 +719,7 @@ def get_sessions( **options: Unpack[SessionListOptions], ) -> SessionPage: """Return a page of the current user's device sessions.""" - with self._operation_lock: + with self._operation(): payload = _mapping( self._authenticated_payload( self._client._transport.auth_get_my_sessions, @@ -734,7 +750,7 @@ def get_sessions( def delete_session(self, *, session_id: str) -> None: """Delete one device session.""" - with self._operation_lock: + with self._operation(): try: normalized_session_id = str(UUID(session_id)) except (TypeError, ValueError, AttributeError) as error: @@ -776,7 +792,7 @@ def _authenticated_payload( retry_unauthorized: bool = True, **kwargs: object, ) -> object: - with self._operation_lock: + with self._operation(): return self._authenticated_payload_locked( operation, expected_status=expected_status, @@ -830,12 +846,12 @@ def _replace_auth( *, preserve_device_sessions: bool = False, ) -> None: - with self._operation_lock: + with self._operation(): if not preserve_device_sessions: self._current_device_session_ids.clear() self._client._commit_auth(session, user) def _clear_auth(self) -> None: - with self._operation_lock: + with self._operation(): self._current_device_session_ids.clear() self._client._clear_auth() diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 8e3e0504..101e2ee8 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -5,7 +5,7 @@ import logging from collections import deque from contextlib import suppress -from threading import RLock +from threading import RLock, local from typing import TYPE_CHECKING, TypedDict, Unpack, cast from ._transport import GeneratedTransport, Transport @@ -40,6 +40,12 @@ def __init__(self, callback: Callable[[User | None], None]) -> None: self.dispatching = False +class _AuthNotificationState(local): + def __init__(self) -> None: + self.depth = 0 + self.listeners: list[_AuthListener] = [] + + class VolcanoClient: """Volcano SDK entry point.""" @@ -76,6 +82,7 @@ def __init__( self._auth_state_lock = RLock() self._auth_listeners: dict[int, _AuthListener] = {} self._next_auth_listener_id = 0 + self._auth_notification_state = _AuthNotificationState() self._transport: Transport = ( cast("Transport", _transport) if _transport is not None @@ -195,9 +202,25 @@ def _queue_auth_listener( return True def _notify_auth_listeners(self, listeners: tuple[_AuthListener, ...]) -> None: + state = self._auth_notification_state + if state.depth: + state.listeners.extend(listeners) + return for listener in listeners: self._drain_auth_listener(listener) + def _begin_auth_notification_deferral(self) -> None: + self._auth_notification_state.depth += 1 + + def _finish_auth_notification_deferral(self) -> Callable[[], None]: + state = self._auth_notification_state + state.depth -= 1 + if state.depth: + return lambda: None + listeners = tuple(state.listeners) + state.listeners.clear() + return lambda: self._notify_auth_listeners(listeners) + def _drain_auth_listener(self, listener: _AuthListener) -> None: while True: with self._auth_state_lock: diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 0a6adf26..651221fa 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -337,9 +337,7 @@ def _discard_closed_loop_authentication(self) -> None: self._callback_task = None self._active_callback_task = None self._callback_stop = None - while not self._callback_queue.empty(): - self._callback_queue.get_nowait() - self._callback_queue.task_done() + self._callback_queue = asyncio.Queue(maxsize=CALLBACK_QUEUE_LIMIT) class Realtime: @@ -409,27 +407,47 @@ async def _subscribe(self, channel: Channel) -> None: await self._subscribe_locked(channel) async def _subscribe_locked(self, channel: Channel) -> None: - if channel._subscription is not None: - await channel._subscription.subscribe() - return - while channel._subscription is None: + while True: generation = channel._auth_generation - connection = await self._connect_locked() - subscription = connection.new_subscription( - channel._name, - events=_ChannelEvents(channel, generation), - ) - channel._subscription = subscription - try: - await subscription.subscribe() - except Exception: - if generation == channel._auth_generation: - raise - channel._subscription = None - continue - if generation != channel._auth_generation: + subscription = channel._subscription + if subscription is None: + connection = await self._connect_locked() + subscription = connection.new_subscription( + channel._name, + events=_ChannelEvents(channel, generation), + ) + channel._subscription = subscription + if await self._subscribe_current_generation( + channel, subscription, generation + ): + return + if channel._subscription is subscription: channel._subscription = None + async def _subscribe_current_generation( + self, + channel: Channel, + subscription: CentrifugeSubscription, + generation: int, + ) -> bool: + try: + await subscription.subscribe() + except Exception: + if generation == channel._auth_generation: + raise + return self._subscription_is_current(channel, subscription, generation) + + @staticmethod + def _subscription_is_current( + channel: Channel, + subscription: CentrifugeSubscription, + generation: int, + ) -> bool: + return ( + generation == channel._auth_generation + and channel._subscription is subscription + ) + async def _publish(self, channel: Channel, data: Any) -> None: async with self._connection_lock: if channel._subscription is None: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index e707e48f..79650ac3 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -471,6 +471,57 @@ def test_sign_in_commits_session_and_user_before_notifying_listeners() -> None: ) +def test_auth_listener_can_wait_for_an_operation_on_another_thread() -> None: + transport = AuthTransport() + transport.queue( + "auth_signin", + AuthResponse( + 200, + _token_payload( + access_token="access-token", + refresh_token="refresh-token", + ), + ), + ) + transport.queue( + "auth_get_user", + AuthResponse( + 200, + { + "user": _token_payload( + access_token="access-token", + refresh_token="refresh-token", + )["user"] + }, + ), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + completed = Event() + outcomes: list[bool] = [] + workers: list[Thread] = [] + started = False + + def load_user() -> None: + client.auth.get_user() + completed.set() + + def listener(user: User | None) -> None: + nonlocal started + if user is None or started: + return + started = True + worker = Thread(target=load_user) + workers.append(worker) + worker.start() + outcomes.append(completed.wait(0.2)) + + client.auth.on_auth_state_change(listener) + client.auth.sign_in(email="user@example.com", password="secret") + workers[0].join(timeout=1) + + assert outcomes == [True] + + def test_get_and_update_user_preserve_the_current_session() -> None: transport = AuthTransport() transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) @@ -1513,6 +1564,26 @@ def test_provider_401_uses_structured_code_to_preserve_session() -> None: assert client.current_session.access_token == "access-token" +def test_provider_401_without_optional_code_preserves_session() -> None: + transport = AuthTransport() + transport.queue( + "call_oauth_provider_api", + AuthResponse(401, {"error": "Provider is not linked"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + + with pytest.raises(AuthenticationError, match="Provider is not linked"): + client.auth.call_oauth_api(provider="github", endpoint="/user") + + assert client.current_session is not None + assert client.current_session.access_token == "access-token" + + def test_provider_api_refreshes_an_expired_session_once() -> None: transport = AuthTransport() transport.queue( diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index fb7b372c..6112830e 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -3,8 +3,9 @@ import json import httpx +import pytest -from volcano_sdk import User +from volcano_sdk import AuthenticationError, User, VolcanoClient from volcano_sdk._transport import GeneratedTransport, TransportResponse @@ -24,6 +25,20 @@ def handle(request: httpx.Request) -> httpx.Response: ) +def test_generated_transport_normalizes_malformed_signup_payloads() -> None: + def handle(_request: httpx.Request) -> httpx.Response: + return httpx.Response(201, json={"message": "created"}) + + transport = GeneratedTransport( + api_url="https://api.test.volcano.dev", + httpx_transport=httpx.MockTransport(handle), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + with pytest.raises(AuthenticationError, match="Invalid authentication response"): + client.auth.sign_up(email="user@example.com", password="secret") + + def _auth_response() -> httpx.Response: return httpx.Response( 200, diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 12df4055..b0a55ead 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -372,13 +372,76 @@ def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: _realtime_client_factory=FakeCentrifugeFactory(official), ) client.auth.sign_in(email="user@example.com", password="secret") - channel = client.realtime.channel("contract") - asyncio.run(channel.subscribe()) + received: list[str] = [] + channel = client.realtime.channel("contract").on( + "message", lambda data: received.append(data["value"]) + ) + + async def exercise(value: str) -> None: + await channel.subscribe() + await official.emit_wire_publication("broadcast:contract", {"value": value}) + for _ in range(10): + if received and received[-1] == value: + return + await asyncio.sleep(0) + assert received + assert received[-1] == value + + asyncio.run(exercise("first")) transport.access_token = "access-2" client.auth.sign_in(email="next@example.com", password="secret") + asyncio.run(exercise("second")) + + assert channel._subscription is official.subscription + assert received == ["first", "second"] + + +def test_auth_change_retries_an_existing_subscription_in_flight( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + first = FakeCentrifugeClient() + second = FakeCentrifugeClient() + clients = iter((first, second)) + + def factory(*args: Any, **kwargs: Any) -> FakeCentrifugeClient: + del args, kwargs + return next(clients) + + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=factory, + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + channel = client.realtime.channel("contract") + await channel.subscribe() + assert first.subscription is not None + entered = asyncio.Event() + release = asyncio.Event() + original_subscribe = first.subscription.subscribe + + async def blocking_subscribe() -> None: + entered.set() + await release.wait() + await original_subscribe() + + monkeypatch.setattr(first.subscription, "subscribe", blocking_subscribe) + subscribing = asyncio.create_task(channel.subscribe()) + await entered.wait() + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + release.set() + await subscribing + + assert second.subscription is not None + assert channel._subscription is second.subscription + await client.realtime.disconnect() - assert channel._subscription is None + asyncio.run(scenario()) def test_realtime_callbacks_run_outside_the_message_processor() -> None: From 5dd73def5fa24d3ba686aa705466c0f0a66533d0 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:14:31 -0400 Subject: [PATCH 23/37] fix: normalize authentication failures --- docs/authentication.md | 2 +- src/volcano_sdk/auth.py | 60 ++++++++++++++++++++++++++++------------- tests/unit/test_auth.py | 55 +++++++++++++++++++++++++++++++++++++ 3 files changed, 98 insertions(+), 19 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 5c48e838..f6b2f5de 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -64,7 +64,7 @@ reused. `sign_out()` also clears local state if the remote revoke fails. List and revoke device sessions through the same facade: ```python -page = client.auth.get_sessions(page=1, limit=20) +page = client.auth.get_sessions(sort="created_at", status="active", limit=20) for device_session in page.sessions: print(device_session.id, device_session.last_activity_at) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 299f6c66..b69fef53 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -45,6 +45,7 @@ _INVALID_OAUTH_PROVIDER = "Unsupported OAuth provider" _MISSING_AUTHORIZATION_URL = "Authentication response is missing authorization URL" _INVALID_SESSION_ID = "session_id must be a valid UUID" +_NO_ACTIVE_SESSION = "No active session" _SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) @@ -154,6 +155,12 @@ def _provider(value: str) -> OAuthProviderName: return cast("OAuthProviderName", value) +def _provider_not_linked(error: AuthenticationError) -> bool: + return error.code == _PROVIDER_NOT_LINKED_CODE or ( + not error.code and "not linked" in str(error).lower() + ) + + def _message(payload: Mapping[str, Any]) -> MessageResult: message = payload.get("message") if not isinstance(message, str): @@ -687,22 +694,34 @@ def call_oauth_api( "method": method, "body": body, } - try: - payload = self._provider_api_payload(arguments) - except AuthenticationError as error: - session = self._client.current_session - if ( - error.status != _HTTP_UNAUTHORIZED - or session is None - or session.refresh_token is None - or error.code == _PROVIDER_NOT_LINKED_CODE - or (not error.code and "not linked" in str(error).lower()) - ): - raise - self.refresh_session() - payload = self._provider_api_payload(arguments) + payload = self._provider_api_payload_with_refresh(arguments) return _json_value(payload) + def _provider_api_payload_with_refresh( + self, + arguments: Mapping[str, object], + ) -> object: + try: + return self._provider_api_payload(arguments) + except AuthenticationError as error: + session = self._client.current_session + if ( + error.status != _HTTP_UNAUTHORIZED + or session is None + or _provider_not_linked(error) + ): + raise + if session.refresh_token is None: + self._clear_auth() + raise + self.refresh_session() + try: + return self._provider_api_payload(arguments) + except AuthenticationError as error: + if error.status == _HTTP_UNAUTHORIZED and not _provider_not_linked(error): + self._clear_auth() + raise + def _provider_api_payload(self, arguments: Mapping[str, object]) -> object: return self._authenticated_payload( self._client._transport.call_oauth_provider_api, @@ -757,8 +776,7 @@ def delete_session(self, *, session_id: str) -> None: raise ValidationError(_INVALID_SESSION_ID) from error deletes_current_session = ( normalized_session_id in self._current_device_session_ids - or normalized_session_id - == _token_session_id(self._client._session_token()) + or normalized_session_id == _token_session_id(self._session_token()) ) self._authenticated_payload( self._client._transport.auth_delete_my_session, @@ -811,7 +829,7 @@ def _authenticated_payload_locked( try: response = invoke( operation, - authorization=self._client._session_token(), + authorization=self._session_token(), **kwargs, ) return response_payload(response, expected_status) @@ -829,7 +847,7 @@ def _authenticated_payload_locked( self.refresh_session() response = invoke( operation, - authorization=self._client._session_token(), + authorization=self._session_token(), **kwargs, ) try: @@ -839,6 +857,12 @@ def _authenticated_payload_locked( self._clear_auth() raise + def _session_token(self) -> str: + session = self._client.current_session + if session is None: + raise AuthenticationError(_NO_ACTIVE_SESSION) + return session.access_token + def _replace_auth( self, session: Session, diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 79650ac3..3ca91601 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -564,6 +564,13 @@ def test_get_and_update_user_preserve_the_current_session() -> None: ] +def test_authenticated_facade_normalizes_a_missing_session() -> None: + client = VolcanoClient(anon_key="anon-key", _transport=AuthTransport()) + + with pytest.raises(AuthenticationError, match="No active session"): + client.auth.get_user() + + def test_refresh_rotates_tokens_and_replaces_user_state() -> None: transport = AuthTransport() transport.queue( @@ -1584,6 +1591,24 @@ def test_provider_401_without_optional_code_preserves_session() -> None: assert client.current_session.access_token == "access-token" +def test_provider_401_clears_an_access_only_session() -> None: + transport = AuthTransport() + transport.queue( + "call_oauth_provider_api", + AuthResponse(401, {"error": "Session expired"}), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + + with pytest.raises(AuthenticationError, match="Session expired"): + client.auth.call_oauth_api(provider="github", endpoint="/user") + + assert client.current_session is None + + def test_provider_api_refreshes_an_expired_session_once() -> None: transport = AuthTransport() transport.queue( @@ -1615,6 +1640,36 @@ def test_provider_api_refreshes_an_expired_session_once() -> None: assert client.current_session.access_token == "rotated-access" +def test_provider_api_clears_auth_after_a_rejected_retry() -> None: + transport = AuthTransport() + transport.queue( + "call_oauth_provider_api", + AuthResponse(401, {"error": "Expired"}), + AuthResponse(401, {"error": "Session revoked"}), + ) + transport.queue( + "auth_refresh", + AuthResponse( + 200, + _token_payload( + access_token="rotated-access", + refresh_token="rotated-refresh", + ), + ), + ) + client = VolcanoClient( + anon_key="anon-key", + access_token="expired-access", + refresh_token="refresh-token", + _transport=transport, + ) + + with pytest.raises(AuthenticationError, match="Session revoked"): + client.auth.call_oauth_api(provider="github", endpoint="/user") + + assert client.current_session is None + + def test_delete_session_rejects_a_malformed_identifier() -> None: client = VolcanoClient( anon_key="anon-key", From c9263910e5ef30f44967a5f4b372fe1995bc0fe0 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:20:45 -0400 Subject: [PATCH 24/37] fix: harden realtime auth transitions --- src/volcano_sdk/_transport.py | 77 ++++++++++++---------- src/volcano_sdk/realtime.py | 71 +++++++++++--------- tests/unit/test_generated_transport.py | 16 +++++ tests/unit/test_realtime.py | 91 +++++++++++++++++++++++++- 4 files changed, 188 insertions(+), 67 deletions(-) diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index bfb89257..b9f59806 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -3,7 +3,8 @@ from __future__ import annotations import json -from collections.abc import Mapping +from collections.abc import Generator, Mapping +from contextlib import contextmanager from dataclasses import dataclass from io import BytesIO from pathlib import PurePosixPath @@ -454,6 +455,17 @@ def _client(self, authorization: str) -> AuthenticatedClient: httpx_args=httpx_args, ) + @contextmanager + def _auth_client( + self, + authorization: str, + ) -> Generator[AuthenticatedClient, None, None]: + try: + with self._client(authorization) as client: + yield client + except (KeyError, TypeError, ValueError): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) from None + @staticmethod def _response(response: Any) -> TransportResponse: parsed = response.parsed @@ -480,7 +492,7 @@ def auth_signin( email: str, password: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_signin.sync_detailed( client=client, body=AuthSigninBody(email=email, password=password), @@ -498,14 +510,11 @@ def auth_signup( body_data: dict[str, Any] = {"email": email, "password": password} if user_metadata is not None: body_data["user_metadata"] = _mutable_json(user_metadata) - try: - with self._client(authorization) as client: - response = auth_signup.sync_detailed( - client=client, - body=AuthSignupBody.from_dict(body_data), - ) - except (KeyError, TypeError, ValueError): - raise AuthenticationError(_INVALID_AUTH_RESPONSE) from None + with self._auth_client(authorization) as client: + response = auth_signup.sync_detailed( + client=client, + body=AuthSignupBody.from_dict(body_data), + ) return self._response(response) def auth_refresh( @@ -514,7 +523,7 @@ def auth_refresh( authorization: str, refresh_token: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_refresh.sync_detailed( client=client, body=AuthRefreshBody(refresh_token=refresh_token), @@ -527,7 +536,7 @@ def auth_logout( authorization: str, refresh_token: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_logout.sync_detailed( client=client, body=AuthLogoutBody(refresh_token=refresh_token), @@ -535,7 +544,7 @@ def auth_logout( return self._response(response) def auth_get_user(self, *, authorization: str) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_get_user.sync_detailed(client=client) return self._response(response) @@ -551,7 +560,7 @@ def auth_update_user( body_data["password"] = password if user_metadata is not None: body_data["user_metadata"] = _mutable_json(user_metadata) - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_update_user.sync_detailed( client=client, body=AuthUpdateUserBody.from_dict(body_data), @@ -569,7 +578,7 @@ def auth_signup_anonymous( if user_metadata is not None else {} ) - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_signup_anonymous.sync_detailed( client=client, body=AuthSignupAnonymousBody.from_dict(body_data), @@ -587,7 +596,7 @@ def auth_convert_anonymous( body_data: dict[str, Any] = {"email": email, "password": password} if user_metadata is not None: body_data["user_metadata"] = _mutable_json(user_metadata) - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_convert_anonymous.sync_detailed( client=client, body=AuthConvertAnonymousBody.from_dict(body_data), @@ -600,7 +609,7 @@ def auth_confirm_email( authorization: str, token: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_confirm_email.sync_detailed( client=client, body=AuthConfirmEmailBody(token=token), @@ -613,7 +622,7 @@ def auth_resend_confirmation( authorization: str, email: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_resend_confirmation.sync_detailed( client=client, body=AuthResendConfirmationBody(email=email), @@ -626,7 +635,7 @@ def auth_forgot_password( authorization: str, email: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_forgot_password.sync_detailed( client=client, body=AuthForgotPasswordBody(email=email), @@ -640,7 +649,7 @@ def auth_reset_password( token: str, new_password: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_reset_password.sync_detailed( client=client, body=AuthResetPasswordBody(token=token, new_password=new_password), @@ -653,7 +662,7 @@ def auth_request_email_change( authorization: str, new_email: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_request_email_change.sync_detailed( client=client, body=AuthRequestEmailChangeBody(new_email=new_email), @@ -666,7 +675,7 @@ def auth_confirm_email_change( authorization: str, email_change_token: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_confirm_email_change.sync_detailed( client=client, body=AuthConfirmEmailChangeBody( @@ -680,7 +689,7 @@ def auth_cancel_email_change( *, authorization: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_cancel_email_change.sync_detailed(client=client) return self._response(response) @@ -692,7 +701,7 @@ def auth_oauth_authorize( redirect_url: str, state: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_o_auth_authorize.sync_detailed( provider, client=client, @@ -710,7 +719,7 @@ def auth_oauth_exchange( code: str, redirect_url: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_o_auth_exchange.sync_detailed( client=client, body=AuthOAuthExchangeBody(code=code, redirect_url=redirect_url), @@ -725,7 +734,7 @@ def auth_link_oauth_provider( redirect_url: str, state: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_link_o_auth_provider.sync_detailed( provider, client=client, @@ -741,7 +750,7 @@ def auth_unlink_oauth_provider( authorization: str, provider: OAuthProviderName, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_unlink_o_auth_provider.sync_detailed( provider, client=client, @@ -753,7 +762,7 @@ def auth_list_oauth_providers( *, authorization: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_list_o_auth_providers.sync_detailed(client=client) return self._response(response) @@ -763,7 +772,7 @@ def refresh_oauth_provider_token( authorization: str, provider: OAuthProviderName, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = refresh_o_auth_provider_token.sync_detailed( provider, client=client, @@ -776,7 +785,7 @@ def get_oauth_provider_token( authorization: str, provider: OAuthProviderName, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = get_o_auth_provider_token.sync_detailed( provider, client=client, @@ -795,7 +804,7 @@ def call_oauth_provider_api( body_data: dict[str, Any] = {"endpoint": endpoint, "method": method} if body is not None: body_data["body"] = _mutable_json(body) - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = client.get_httpx_client().post( f"/auth/oauth/{quote(provider, safe='')}/call-api", json=body_data, @@ -819,7 +828,7 @@ def auth_get_my_sessions( limit: int = 20, **options: Unpack[SessionListOptions], ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_get_my_sessions.sync_detailed( client=client, page=page if page is not None else UNSET, @@ -838,7 +847,7 @@ def auth_delete_my_session( authorization: str, session_id: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_delete_my_session.sync_detailed( UUID(session_id), client=client, @@ -850,7 +859,7 @@ def auth_delete_all_my_sessions( *, authorization: str, ) -> TransportResponse: - with self._client(authorization) as client: + with self._auth_client(authorization) as client: response = auth_delete_all_my_sessions.sync_detailed(client=client) return self._response(response) diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 651221fa..e5ea743c 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -7,7 +7,6 @@ import inspect import logging from collections.abc import Awaitable, Callable -from threading import Event as ThreadEvent from typing import Any, Protocol, cast from urllib.parse import quote, urlsplit, urlunsplit @@ -214,7 +213,7 @@ def __init__(self, realtime: Realtime, name: str) -> None: self._name = name self._message_callbacks: list[MessageCallback] = [] self._subscription: CentrifugeSubscription | None = None - self._callback_queue: asyncio.Queue[Any] = asyncio.Queue( + self._callback_queue: asyncio.Queue[tuple[int, Any]] = asyncio.Queue( maxsize=CALLBACK_QUEUE_LIMIT ) self._callback_task: asyncio.Task[None] | None = None @@ -252,7 +251,7 @@ async def _emit(self, data: Any) -> None: ) self._callback_stop = None try: - self._callback_queue.put_nowait(data) + self._callback_queue.put_nowait((self._auth_generation, data)) except asyncio.QueueFull: asyncio.get_running_loop().call_exception_handler( { @@ -274,30 +273,40 @@ async def _restart_callback_dispatcher(self, previous: asyncio.Task[None]) -> No async def _dispatch_callbacks(self, stop: asyncio.Event) -> None: while not stop.is_set(): - data = await self._callback_queue.get() + generation, data = await self._callback_queue.get() try: for callback in tuple(self._message_callbacks): - active_task = asyncio.create_task( - self._run_callback(callback, data) - ) - self._active_callback_task = active_task - try: - (error,) = await asyncio.gather( - active_task, return_exceptions=True - ) - finally: - self._active_callback_task = None - if isinstance(error, BaseException): - asyncio.get_running_loop().call_exception_handler( - { - "message": "Volcano realtime callback failed", - "exception": error, - "channel": self._name, - } - ) + if not await self._dispatch_callback(callback, data, generation): + break finally: self._callback_queue.task_done() + async def _dispatch_callback( + self, + callback: MessageCallback, + data: Any, + generation: int, + ) -> bool: + if generation != self._auth_generation: + return False + active_task = asyncio.create_task(self._run_callback(callback, data)) + self._active_callback_task = active_task + try: + (error,) = await asyncio.gather(active_task, return_exceptions=True) + finally: + self._active_callback_task = None + if generation != self._auth_generation: + return False + if isinstance(error, BaseException): + asyncio.get_running_loop().call_exception_handler( + { + "message": "Volcano realtime callback failed", + "exception": error, + "channel": self._name, + } + ) + return True + async def _run_callback(self, callback: MessageCallback, data: Any) -> None: result = callback(data) if inspect.isawaitable(result): @@ -486,7 +495,10 @@ def on_auth_change(self) -> None: self._discard_closed_loop_authentication() return if loop is not None and _running_loop() is not loop: - self._schedule_auth_invalidation(loop) + if loop.is_running(): + self._schedule_auth_invalidation(loop) + else: + self._discard_closed_loop_authentication() return self._invalidate_authentication() @@ -494,16 +506,10 @@ def _schedule_auth_invalidation( self, loop: asyncio.AbstractEventLoop, ) -> None: - completed = ThreadEvent() - loop.call_soon_threadsafe(self._invalidate_and_signal, completed) - if loop.is_running(): - completed.wait() - - def _invalidate_and_signal(self, completed: ThreadEvent) -> None: try: - self._invalidate_authentication() - finally: - completed.set() + loop.call_soon_threadsafe(self._invalidate_authentication) + except RuntimeError: + self._discard_closed_loop_authentication() def _invalidate_authentication(self) -> None: self._auth_generation += 1 @@ -523,6 +529,7 @@ def _invalidate_authentication(self) -> None: def _discard_closed_loop_authentication(self) -> None: self._auth_generation += 1 self._connection = None + self._connection_lock = asyncio.Lock() self._loop = None self._auth_cleanup_tasks.clear() self._in_flight_publishes.clear() diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 6112830e..4521b3ea 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -39,6 +39,22 @@ def handle(_request: httpx.Request) -> httpx.Response: client.auth.sign_up(email="user@example.com", password="secret") +def test_generated_transport_normalizes_malformed_signin_payloads() -> None: + def handle(_request: httpx.Request) -> httpx.Response: + payload = _auth_response().json() + del payload["token_type"] + return httpx.Response(200, json=payload) + + transport = GeneratedTransport( + api_url="https://api.test.volcano.dev", + httpx_transport=httpx.MockTransport(handle), + ) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + with pytest.raises(AuthenticationError, match="Invalid authentication response"): + client.auth.sign_in(email="user@example.com", password="secret") + + def _auth_response() -> httpx.Response: return httpx.Response( 200, diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index b0a55ead..ee3ce91d 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass, field +from threading import Event, Thread from types import SimpleNamespace from typing import TYPE_CHECKING, Any @@ -363,6 +364,49 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_event_loop_listener_does_not_block_a_worker_auth_change() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + completed = Event() + outcomes: list[bool] = [] + workers: list[Thread] = [] + started = False + + def replace_auth() -> None: + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + completed.set() + + def listener(user: Any | None) -> None: + nonlocal started + if user is None or started: + return + started = True + worker = Thread(target=replace_auth) + workers.append(worker) + worker.start() + outcomes.append(completed.wait(1)) + + async def scenario() -> None: + channel = client.realtime.channel("contract") + await channel.subscribe() + client.auth.on_auth_state_change(listener) + await asyncio.sleep(0) + assert channel._subscription is None + await client.realtime.disconnect() + + asyncio.run(scenario()) + workers[0].join(timeout=1) + + assert outcomes == [True] + + def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: transport = AuthTransport() official = FakeCentrifugeClient() @@ -378,7 +422,12 @@ def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: ) async def exercise(value: str) -> None: - await channel.subscribe() + connection_lock = client.realtime._connection_lock + await connection_lock.acquire() + subscribing = asyncio.create_task(channel.subscribe()) + await asyncio.sleep(0) + connection_lock.release() + await subscribing await official.emit_wire_publication("broadcast:contract", {"value": value}) for _ in range(10): if received and received[-1] == value: @@ -480,6 +529,46 @@ async def callback(data: Any) -> None: asyncio.run(scenario()) +def test_auth_change_stops_the_current_publication_callback_chain() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + channel = client.realtime.channel("contract") + started = asyncio.Event() + release = asyncio.Event() + later_callbacks: list[str] = [] + + async def first_callback(data: dict[str, str]) -> None: + del data + started.set() + await release.wait() + + channel.on("message", first_callback) + channel.on("message", lambda data: later_callbacks.append(data["value"])) + await channel.subscribe() + await official.emit_wire_publication( + "broadcast:contract", + {"value": "stale"}, + ) + await asyncio.wait_for(started.wait(), timeout=0.1) + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + release.set() + await asyncio.sleep(0) + + assert later_callbacks == [] + await client.realtime.disconnect() + + asyncio.run(scenario()) + + def test_realtime_routes_overlapping_channel_suffixes_to_the_longest_match() -> None: transport = AuthTransport() official = FakeCentrifugeClient() From 933d2a87dabf2216416464e7307fb646c6249670 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:47:10 -0400 Subject: [PATCH 25/37] fix(auth): close facade review gaps --- docs/authentication.md | 25 +++++++++ src/volcano_sdk/__init__.py | 6 +++ src/volcano_sdk/_transport.py | 58 +++++++++++++++++++++ src/volcano_sdk/auth.py | 97 +++++++++++++++++++++++++++++++++++ src/volcano_sdk/models.py | 27 ++++++++++ src/volcano_sdk/realtime.py | 5 +- tests/unit/test_auth.py | 96 ++++++++++++++++++++++++++++++++++ tests/unit/test_import.py | 3 ++ tests/unit/test_realtime.py | 24 +++++++++ 9 files changed, 337 insertions(+), 4 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index f6b2f5de..a62db955 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -193,5 +193,30 @@ profile = client.auth.call_oauth_api( client.auth.unlink_oauth_provider(provider="github") ``` +## Manage identities and sign-in methods + +List the email identities and sign-in methods owned by the current account: + +```python +identities = client.auth.list_identities() +methods = client.auth.list_methods() + +for identity in identities: + print(identity.email, identity.is_primary) + +for method in methods: + print(method.type, method.provider, method.is_primary) +``` + +Promote a sign-in method or unlink a non-primary identity by its ID: + +```python +promoted = client.auth.promote_method(method_id="method-uuid") +client.auth.unlink_identity(identity_id="identity-uuid") +``` + +The API refuses to unlink a primary or last identity, or an identity whose +removal would leave the account without a sign-in method. + Keep generated state values and provider tokens secret. Navigate to the returned authorization URL only after storing its matching state value. diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index 5849d07c..c15f3194 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -12,6 +12,9 @@ VolcanoError, ) from .models import ( + AuthIdentity, + AuthMethod, + AuthMethodType, AuthorizationRequest, AuthSession, EmailChangeResult, @@ -29,6 +32,9 @@ ) __all__ = [ + "AuthIdentity", + "AuthMethod", + "AuthMethodType", "AuthSession", "AuthenticationError", "AuthorizationRequest", diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index b9f59806..288b8c7b 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -24,7 +24,10 @@ auth_forgot_password, auth_get_my_sessions, auth_get_user, + auth_list_identities, + auth_list_methods, auth_logout, + auth_promote_method, auth_refresh, auth_request_email_change, auth_resend_confirmation, @@ -32,6 +35,7 @@ auth_signin, auth_signup, auth_signup_anonymous, + auth_unlink_identity, auth_update_user, ) from ._generated.api.database_queries import query_database_select @@ -332,6 +336,24 @@ def auth_delete_all_my_sessions( authorization: str, ) -> TransportResponse: ... + def auth_list_identities(self, *, authorization: str) -> TransportResponse: ... + + def auth_unlink_identity( + self, + *, + authorization: str, + identity_id: str, + ) -> TransportResponse: ... + + def auth_list_methods(self, *, authorization: str) -> TransportResponse: ... + + def auth_promote_method( + self, + *, + authorization: str, + method_id: str, + ) -> TransportResponse: ... + def query_database_select( self, *, @@ -863,6 +885,42 @@ def auth_delete_all_my_sessions( response = auth_delete_all_my_sessions.sync_detailed(client=client) return self._response(response) + def auth_list_identities(self, *, authorization: str) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_list_identities.sync_detailed(client=client) + return self._response(response) + + def auth_unlink_identity( + self, + *, + authorization: str, + identity_id: str, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_unlink_identity.sync_detailed( + UUID(identity_id), + client=client, + ) + return self._response(response) + + def auth_list_methods(self, *, authorization: str) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_list_methods.sync_detailed(client=client) + return self._response(response) + + def auth_promote_method( + self, + *, + authorization: str, + method_id: str, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_promote_method.sync_detailed( + UUID(method_id), + client=client, + ) + return self._response(response) + def query_database_select( self, *, diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index b69fef53..cc638a99 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -18,6 +18,9 @@ from ._transport import Transport, TransportResponse, invoke, response_payload from .errors import AuthenticationError, ValidationError, VolcanoError from .models import ( + AuthIdentity, + AuthMethod, + AuthMethodType, AuthorizationRequest, AuthSession, EmailChangeResult, @@ -47,6 +50,7 @@ _INVALID_SESSION_ID = "session_id must be a valid UUID" _NO_ACTIVE_SESSION = "No active session" _SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) +_SUPPORTED_AUTH_METHODS = frozenset({"password", "oauth", "anonymous"}) def _token_session_id(access_token: str) -> str | None: @@ -212,6 +216,54 @@ def _auth_session(payload: Mapping[str, Any]) -> AuthSession: ) +def _required_text(payload: Mapping[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return value + + +def _required_bool(payload: Mapping[str, Any], key: str) -> bool: + value = payload.get(key) + if not isinstance(value, bool): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return value + + +def _required_datetime(payload: Mapping[str, Any], key: str) -> datetime: + value = _optional_datetime(payload.get(key)) + if value is None: + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return value + + +def _auth_identity(payload: Mapping[str, Any]) -> AuthIdentity: + return AuthIdentity( + id=_required_text(payload, "id"), + email=_required_text(payload, "email"), + email_verified=_required_bool(payload, "email_verified"), + is_primary=_required_bool(payload, "is_primary"), + created_at=_required_datetime(payload, "created_at"), + ) + + +def _auth_method(payload: Mapping[str, Any]) -> AuthMethod: + method_type = _required_text(payload, "type") + if method_type not in _SUPPORTED_AUTH_METHODS: + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return AuthMethod( + id=_required_text(payload, "id"), + type=cast("AuthMethodType", method_type), + provider=_optional_text(payload.get("provider")), + identity_id=_required_text(payload, "identity_id"), + email=_required_text(payload, "email"), + is_primary=_required_bool(payload, "is_primary"), + last_used_at=_optional_datetime(payload.get("last_used_at")), + created_at=_required_datetime(payload, "created_at"), + updated_at=_required_datetime(payload, "updated_at"), + ) + + def _user(payload: Mapping[str, Any]) -> User: user_id = payload.get("id") if not isinstance(user_id, str): @@ -730,6 +782,51 @@ def _provider_api_payload(self, arguments: Mapping[str, object]) -> object: **arguments, ) + def list_identities(self) -> tuple[AuthIdentity, ...]: + """Return verified email identities owned by the current user.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_list_identities, + expected_status=200, + ) + ) + identities = payload.get("identities") + if not isinstance(identities, list): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + identity_values = cast("list[object]", identities) + return tuple(_auth_identity(_mapping(item)) for item in identity_values) + + def unlink_identity(self, *, identity_id: str) -> None: + """Unlink a non-primary identity from the current user.""" + self._authenticated_payload( + self._client._transport.auth_unlink_identity, + expected_status=204, + identity_id=identity_id, + ) + + def list_methods(self) -> tuple[AuthMethod, ...]: + """Return sign-in methods owned by the current user.""" + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_list_methods, + expected_status=200, + ) + ) + methods = payload.get("methods") + if not isinstance(methods, list): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + method_values = cast("list[object]", methods) + return tuple(_auth_method(_mapping(item)) for item in method_values) + + def promote_method(self, *, method_id: str) -> AuthMethod: + """Make a sign-in method the account's primary method.""" + payload = self._authenticated_payload( + self._client._transport.auth_promote_method, + expected_status=200, + method_id=method_id, + ) + return _auth_method(_mapping(payload)) + def get_sessions( self, *, diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index a7c92558..a2883f80 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -22,6 +22,7 @@ | None ) OAuthProviderName: TypeAlias = Literal["google", "github", "microsoft", "apple"] +AuthMethodType: TypeAlias = Literal["password", "oauth", "anonymous"] class SessionListOptions(TypedDict, total=False): @@ -143,6 +144,32 @@ class OAuthTokenResult: message: str | None = None +@dataclass(frozen=True, slots=True) +class AuthIdentity: + """Verified email identity owned by the current user.""" + + id: str + email: str + email_verified: bool + is_primary: bool + created_at: datetime + + +@dataclass(frozen=True, slots=True) +class AuthMethod: + """Sign-in method owned by the current user.""" + + id: str + type: AuthMethodType + identity_id: str + email: str + is_primary: bool + created_at: datetime + updated_at: datetime + provider: str | None = None + last_used_at: datetime | None = None + + @dataclass(frozen=True, slots=True) class AuthSession: """Device session associated with the current user.""" diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index e5ea743c..8546c387 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -495,10 +495,7 @@ def on_auth_change(self) -> None: self._discard_closed_loop_authentication() return if loop is not None and _running_loop() is not loop: - if loop.is_running(): - self._schedule_auth_invalidation(loop) - else: - self._discard_closed_loop_authentication() + self._schedule_auth_invalidation(loop) return self._invalidate_authentication() diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 3ca91601..f89e4d36 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -11,6 +11,8 @@ from typing_extensions import override from volcano_sdk import ( + AuthIdentity, + AuthMethod, AuthorizationRequest, AuthSession, EmailChangeResult, @@ -155,6 +157,18 @@ def auth_unlink_oauth_provider(self, **kwargs: Any) -> AuthResponse: def auth_list_oauth_providers(self, **kwargs: Any) -> AuthResponse: return self._invoke("auth_list_oauth_providers", kwargs) + def auth_list_identities(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_list_identities", kwargs) + + def auth_unlink_identity(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_unlink_identity", kwargs) + + def auth_list_methods(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_list_methods", kwargs) + + def auth_promote_method(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_promote_method", kwargs) + def refresh_oauth_provider_token(self, **kwargs: Any) -> AuthResponse: return self._invoke("refresh_oauth_provider_token", kwargs) @@ -227,6 +241,22 @@ def test_public_auth_values_are_frozen_and_slotted() -> None: expires_in=3600, message="Refreshed", ), + AuthIdentity( + id="identity-id", + email="user@example.com", + email_verified=True, + is_primary=True, + created_at=datetime(2026, 8, 27, tzinfo=UTC), + ), + AuthMethod( + id="method-id", + type="password", + identity_id="identity-id", + email="user@example.com", + is_primary=True, + created_at=datetime(2026, 8, 27, tzinfo=UTC), + updated_at=datetime(2026, 8, 28, tzinfo=UTC), + ), AuthSession( id="session-123", user_id="user-123", @@ -1296,6 +1326,72 @@ def test_oauth_exchange_rejects_non_ascii_state_without_calling_transport() -> N assert transport.calls == [] +def test_identity_management_returns_public_immutable_values() -> None: + transport = AuthTransport() + identity_payload = { + "id": "3cd3e058-e3ff-42a5-ae4d-650ef9b45746", + "email": "user@example.com", + "email_verified": True, + "is_primary": True, + "created_at": "2026-08-27T12:00:00Z", + } + method_payload = { + "id": "7f518a4b-407b-4121-907b-d72a2c7c1ac6", + "type": "oauth", + "provider": "github", + "identity_id": identity_payload["id"], + "email": identity_payload["email"], + "is_primary": True, + "last_used_at": "2026-08-28T12:00:00Z", + "created_at": "2026-08-27T12:00:00Z", + "updated_at": "2026-08-28T12:00:00Z", + } + transport.queue( + "auth_list_identities", + AuthResponse(200, {"identities": [identity_payload]}), + ) + transport.queue( + "auth_list_methods", + AuthResponse(200, {"methods": [method_payload]}), + ) + transport.queue("auth_promote_method", AuthResponse(200, method_payload)) + transport.queue("auth_unlink_identity", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + + identities = client.auth.list_identities() + methods = client.auth.list_methods() + identity_id = cast("str", identity_payload["id"]) + method_id = cast("str", method_payload["id"]) + promoted = client.auth.promote_method(method_id=method_id) + client.auth.unlink_identity(identity_id=identity_id) + + assert identities == ( + AuthIdentity( + id=identity_id, + email="user@example.com", + email_verified=True, + is_primary=True, + created_at=datetime(2026, 8, 27, 12, tzinfo=UTC), + ), + ) + assert methods == (promoted,) + assert promoted == AuthMethod( + id=method_id, + type="oauth", + provider="github", + identity_id=identity_id, + email="user@example.com", + is_primary=True, + last_used_at=datetime(2026, 8, 28, 12, tzinfo=UTC), + created_at=datetime(2026, 8, 27, 12, tzinfo=UTC), + updated_at=datetime(2026, 8, 28, 12, tzinfo=UTC), + ) + + def test_provider_and_device_session_flows_return_public_values() -> None: transport = AuthTransport() transport.queue("auth_unlink_oauth_provider", AuthResponse(204)) diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py index 1ed16a07..0f2224f7 100644 --- a/tests/unit/test_import.py +++ b/tests/unit/test_import.py @@ -8,6 +8,9 @@ def test_package_exports_client() -> None: def test_package_exports_the_public_sdk_contract() -> None: assert volcano_sdk.__all__ == [ + "AuthIdentity", + "AuthMethod", + "AuthMethodType", "AuthSession", "AuthenticationError", "AuthorizationRequest", diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index ee3ce91d..a9ced014 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -446,6 +446,30 @@ async def exercise(value: str) -> None: assert received == ["first", "second"] +def test_auth_change_queues_cleanup_on_a_stopped_reusable_loop() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + channel = client.realtime.channel("contract") + loop = asyncio.new_event_loop() + try: + loop.run_until_complete(channel.subscribe()) + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + loop.run_until_complete(asyncio.sleep(0)) + + assert official.calls[-1] == "disconnect" + assert channel._subscription is None + loop.run_until_complete(client.realtime.disconnect()) + finally: + loop.close() + + def test_auth_change_retries_an_existing_subscription_in_flight( monkeypatch: pytest.MonkeyPatch, ) -> None: From 8cbe66f419db0fb860c4755f53fb8fc2374da6d0 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 16:57:06 -0400 Subject: [PATCH 26/37] fix(auth): refresh user after method promotion --- src/volcano_sdk/auth.py | 19 ++++++++++++++++--- tests/unit/test_auth.py | 22 ++++++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index cc638a99..bb84863d 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -48,6 +48,8 @@ _INVALID_OAUTH_PROVIDER = "Unsupported OAuth provider" _MISSING_AUTHORIZATION_URL = "Authentication response is missing authorization URL" _INVALID_SESSION_ID = "session_id must be a valid UUID" +_INVALID_IDENTITY_ID = "identity_id must be a valid UUID" +_INVALID_METHOD_ID = "method_id must be a valid UUID" _NO_ACTIVE_SESSION = "No active session" _SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) _SUPPORTED_AUTH_METHODS = frozenset({"password", "oauth", "anonymous"}) @@ -67,6 +69,13 @@ def _token_session_id(access_token: str) -> str | None: return None +def _validated_uuid(value: str, message: str) -> str: + try: + return str(UUID(value)) + except (TypeError, ValueError, AttributeError) as error: + raise ValidationError(message) from error + + class AuthContext(Protocol): """Client capabilities required by the authentication facade.""" @@ -798,10 +807,11 @@ def list_identities(self) -> tuple[AuthIdentity, ...]: def unlink_identity(self, *, identity_id: str) -> None: """Unlink a non-primary identity from the current user.""" + normalized_identity_id = _validated_uuid(identity_id, _INVALID_IDENTITY_ID) self._authenticated_payload( self._client._transport.auth_unlink_identity, expected_status=204, - identity_id=identity_id, + identity_id=normalized_identity_id, ) def list_methods(self) -> tuple[AuthMethod, ...]: @@ -820,12 +830,15 @@ def list_methods(self) -> tuple[AuthMethod, ...]: def promote_method(self, *, method_id: str) -> AuthMethod: """Make a sign-in method the account's primary method.""" + normalized_method_id = _validated_uuid(method_id, _INVALID_METHOD_ID) payload = self._authenticated_payload( self._client._transport.auth_promote_method, expected_status=200, - method_id=method_id, + method_id=normalized_method_id, ) - return _auth_method(_mapping(payload)) + method = _auth_method(_mapping(payload)) + self._refresh_user_best_effort() + return method def get_sessions( self, diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index f89e4d36..291decd9 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1355,6 +1355,10 @@ def test_identity_management_returns_public_immutable_values() -> None: AuthResponse(200, {"methods": [method_payload]}), ) transport.queue("auth_promote_method", AuthResponse(200, method_payload)) + transport.queue( + "auth_get_user", + AuthResponse(200, {"user": _user_payload(email="primary@example.com")}), + ) transport.queue("auth_unlink_identity", AuthResponse(204)) client = VolcanoClient( anon_key="anon-key", @@ -1390,6 +1394,24 @@ def test_identity_management_returns_public_immutable_values() -> None: created_at=datetime(2026, 8, 27, 12, tzinfo=UTC), updated_at=datetime(2026, 8, 28, 12, tzinfo=UTC), ) + assert client.current_user is not None + assert client.current_user.email == "primary@example.com" + + +def test_identity_management_rejects_malformed_ids_before_transport() -> None: + transport = AuthTransport() + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + _transport=transport, + ) + + with pytest.raises(ValidationError, match="identity_id must be a valid UUID"): + client.auth.unlink_identity(identity_id="not-a-uuid") + with pytest.raises(ValidationError, match="method_id must be a valid UUID"): + client.auth.promote_method(method_id="not-a-uuid") + + assert transport.calls == [] def test_provider_and_device_session_flows_return_public_values() -> None: From 2ce360e8f4eaba07d216981d0ae6f4ef5586c2c3 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:01:07 -0400 Subject: [PATCH 27/37] test(realtime): await scheduled auth cleanup --- tests/unit/test_realtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index a9ced014..632de4ac 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -357,6 +357,7 @@ async def scenario() -> None: ) await asyncio.sleep(0) assert channel._subscription is None + await asyncio.gather(*tuple(client.realtime._auth_cleanup_tasks)) assert first.calls[-1] == "disconnect" await channel.subscribe() await client.realtime.disconnect() From 7a1847bb5d73fe59d3cad26db7f353bb1198824a Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:24:53 -0400 Subject: [PATCH 28/37] feat(auth): complete stable authentication surface --- docs/authentication.md | 31 +++++ src/volcano_sdk/__init__.py | 10 ++ src/volcano_sdk/_transport.py | 119 ++++++++++++++++++- src/volcano_sdk/auth.py | 115 ++++++++++++++++++ src/volcano_sdk/models.py | 45 +++++++ tests/unit/test_auth.py | 155 ++++++++++++++++++++++++- tests/unit/test_generated_transport.py | 36 ++++++ tests/unit/test_import.py | 5 + 8 files changed, 512 insertions(+), 4 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index a62db955..27e5f07d 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -81,6 +81,37 @@ client.auth.delete_session(session_id="session-id") client.auth.delete_all_other_sessions() ``` +## Use password policy and device authorization + +Read the server-enforced policy instead of duplicating password rules: + +```python +policy = client.auth.get_password_policy() +print(policy.effective_min_length, policy.compromised_passwords_rejected) +``` + +An RFC 8628 device client starts authorization and polls at the returned +interval. A successful poll commits the returned user and session to that +client. The signed-in verifier approves the code on a separate client: + +```python +authorization = device_client.auth.start_device_authorization(client_id="volcano-cli") +print(authorization.verification_uri, authorization.user_code) + +verifier.auth.verify_device(user_code=authorization.user_code, action="approve") +session = device_client.auth.poll_device_token( + client_id="volcano-cli", + device_code=authorization.device_code, +) +``` + +Signed-in clients can also exchange their session for a short-lived platform +token. Treat `token.token` as a secret: + +```python +token = client.auth.exchange_platform_token(client_id="volcano-cli") +``` + ## Create and update accounts Sign-up can return without a session when email confirmation is required. diff --git a/src/volcano_sdk/__init__.py b/src/volcano_sdk/__init__.py index c15f3194..d3e0d0ce 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -17,6 +17,9 @@ AuthMethodType, AuthorizationRequest, AuthSession, + DeviceAuthorization, + DeviceVerification, + DeviceVerificationAction, EmailChangeResult, JSONValue, LockLease, @@ -24,6 +27,8 @@ OAuthProvider, OAuthProviderName, OAuthTokenResult, + PasswordPolicy, + PlatformToken, Session, SessionListOptions, SessionPage, @@ -39,6 +44,9 @@ "AuthenticationError", "AuthorizationRequest", "ConflictError", + "DeviceAuthorization", + "DeviceVerification", + "DeviceVerificationAction", "EmailChangeResult", "JSONValue", "LockLease", @@ -47,6 +55,8 @@ "OAuthProvider", "OAuthProviderName", "OAuthTokenResult", + "PasswordPolicy", + "PlatformToken", "RateLimitedError", "ServerError", "Session", diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index 288b8c7b..97bfde23 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -23,6 +23,7 @@ auth_delete_my_session, auth_forgot_password, auth_get_my_sessions, + auth_get_password_policy, auth_get_user, auth_list_identities, auth_list_methods, @@ -41,10 +42,14 @@ from ._generated.api.database_queries import query_database_select from ._generated.api.locks import acquire_project_lock, release_project_lock from ._generated.api.o_auth_authentication import ( + auth_device_authorize, + auth_device_token, + auth_device_verify, auth_link_o_auth_provider, auth_list_o_auth_providers, auth_o_auth_authorize, auth_o_auth_exchange, + auth_platform_exchange, auth_unlink_o_auth_provider, get_o_auth_provider_token, refresh_o_auth_provider_token, @@ -59,9 +64,13 @@ AuthConfirmEmailChangeBody, ) from ._generated.models.auth_convert_anonymous_body import AuthConvertAnonymousBody +from ._generated.models.auth_device_authorize_body import AuthDeviceAuthorizeBody +from ._generated.models.auth_device_token_body import AuthDeviceTokenBody +from ._generated.models.auth_device_verify_body import AuthDeviceVerifyBody from ._generated.models.auth_forgot_password_body import AuthForgotPasswordBody from ._generated.models.auth_logout_body import AuthLogoutBody from ._generated.models.auth_o_auth_exchange_body import AuthOAuthExchangeBody +from ._generated.models.auth_platform_exchange_body import AuthPlatformExchangeBody from ._generated.models.auth_refresh_body import AuthRefreshBody from ._generated.models.auth_request_email_change_body import ( AuthRequestEmailChangeBody, @@ -94,7 +103,12 @@ if TYPE_CHECKING: from collections.abc import Callable - from .models import JSONValue, OAuthProviderName, SessionListOptions + from .models import ( + DeviceVerificationAction, + JSONValue, + OAuthProviderName, + SessionListOptions, + ) def _mutable_json(value: JSONValue) -> JSONValue: @@ -145,6 +159,42 @@ class _GeneratedTransportResponse: class Transport(Protocol): + def auth_get_password_policy( + self, + *, + authorization: str, + ) -> TransportResponse: ... + + def auth_device_authorize( + self, + *, + authorization: str, + client_id: str, + ) -> TransportResponse: ... + + def auth_device_token( + self, + *, + authorization: str, + client_id: str, + device_code: str, + ) -> TransportResponse: ... + + def auth_device_verify( + self, + *, + authorization: str, + user_code: str, + action: DeviceVerificationAction, + ) -> TransportResponse: ... + + def auth_platform_exchange( + self, + *, + authorization: str, + client_id: str, + ) -> TransportResponse: ... + def auth_signup( self, *, @@ -521,6 +571,73 @@ def auth_signin( ) return self._response(response) + def auth_get_password_policy( + self, + *, + authorization: str, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_get_password_policy.sync_detailed(client=client) + return self._response(response) + + def auth_device_authorize( + self, + *, + authorization: str, + client_id: str, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_device_authorize.sync_detailed( + client=client, + body=AuthDeviceAuthorizeBody(client_id=client_id), + ) + return self._response(response) + + def auth_device_token( + self, + *, + authorization: str, + client_id: str, + device_code: str, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_device_token.sync_detailed( + client=client, + body=AuthDeviceTokenBody( + grant_type="urn:ietf:params:oauth:grant-type:device_code", + device_code=device_code, + client_id=client_id, + ), + ) + return self._response(response) + + def auth_device_verify( + self, + *, + authorization: str, + user_code: str, + action: DeviceVerificationAction, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_device_verify.sync_detailed( + client=client, + body=AuthDeviceVerifyBody(user_code=user_code, action=action), + ) + return self._response(response) + + def auth_platform_exchange( + self, + *, + authorization: str, + client_id: str, + ) -> TransportResponse: + with self._auth_client(authorization) as client: + response = auth_platform_exchange.sync_detailed( + client=client, + body=AuthPlatformExchangeBody(client_id=client_id), + ) + return self._response(response) + def auth_signup( self, *, diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index bb84863d..ccde8d0e 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -7,6 +7,7 @@ from base64 import urlsafe_b64decode from collections.abc import Generator, Mapping from contextlib import contextmanager, suppress +from dataclasses import replace from datetime import datetime from hmac import compare_digest from secrets import token_urlsafe @@ -23,11 +24,16 @@ AuthMethodType, AuthorizationRequest, AuthSession, + DeviceAuthorization, + DeviceVerification, + DeviceVerificationAction, EmailChangeResult, MessageResult, OAuthProvider, OAuthProviderName, OAuthTokenResult, + PasswordPolicy, + PlatformToken, Session, SessionListOptions, SessionPage, @@ -50,9 +56,11 @@ _INVALID_SESSION_ID = "session_id must be a valid UUID" _INVALID_IDENTITY_ID = "identity_id must be a valid UUID" _INVALID_METHOD_ID = "method_id must be a valid UUID" +_INVALID_DEVICE_ACTION = "action must be approve or deny" _NO_ACTIVE_SESSION = "No active session" _SUPPORTED_OAUTH_PROVIDERS = frozenset({"google", "github", "microsoft", "apple"}) _SUPPORTED_AUTH_METHODS = frozenset({"password", "oauth", "anonymous"}) +_SUPPORTED_DEVICE_ACTIONS = frozenset({"approve", "deny"}) def _token_session_id(access_token: str) -> str | None: @@ -239,6 +247,13 @@ def _required_bool(payload: Mapping[str, Any], key: str) -> bool: return value +def _required_int(payload: Mapping[str, Any], key: str) -> int: + value = payload.get(key) + if type(value) is not int: + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + return value + + def _required_datetime(payload: Mapping[str, Any], key: str) -> datetime: value = _optional_datetime(payload.get(key)) if value is None: @@ -273,6 +288,48 @@ def _auth_method(payload: Mapping[str, Any]) -> AuthMethod: ) +def _password_policy(payload: Mapping[str, Any]) -> PasswordPolicy: + return PasswordPolicy( + effective_min_length=_required_int(payload, "effective_min_length"), + min_configurable_length=_required_int(payload, "min_configurable_length"), + max_length=_required_int(payload, "max_length"), + require_uppercase=_required_bool(payload, "require_uppercase"), + require_lowercase=_required_bool(payload, "require_lowercase"), + require_numbers=_required_bool(payload, "require_numbers"), + require_special_chars=_required_bool(payload, "require_special_chars"), + compromised_passwords_rejected=_required_bool( + payload, "compromised_passwords_rejected" + ), + ) + + +def _device_authorization(payload: Mapping[str, Any]) -> DeviceAuthorization: + return DeviceAuthorization( + device_code=_required_text(payload, "device_code"), + user_code=_required_text(payload, "user_code"), + verification_uri=_required_text(payload, "verification_uri"), + verification_uri_complete=_required_text(payload, "verification_uri_complete"), + expires_in=_required_int(payload, "expires_in"), + interval=_required_int(payload, "interval"), + ) + + +def _device_verification(payload: Mapping[str, Any]) -> DeviceVerification: + return DeviceVerification( + success=_required_bool(payload, "success"), + status=_required_text(payload, "status"), + ) + + +def _platform_token(payload: Mapping[str, Any]) -> PlatformToken: + return PlatformToken( + token=_required_text(payload, "token"), + user_id=_required_text(payload, "user_id"), + token_id=_required_text(payload, "token_id"), + expires_at=_required_datetime(payload, "expires_at"), + ) + + def _user(payload: Mapping[str, Any]) -> User: user_id = payload.get("id") if not isinstance(user_id, str): @@ -370,6 +427,61 @@ def sign_up( message=message, ) + def get_password_policy(self) -> PasswordPolicy: + """Return the backend-enforced password policy.""" + response = invoke( + self._client._transport.auth_get_password_policy, + authorization=self._client._anon_token(), + ) + return _password_policy(_mapping(response_payload(response, 200))) + + def start_device_authorization(self, *, client_id: str) -> DeviceAuthorization: + """Start an RFC 8628 device authorization.""" + response = invoke( + self._client._transport.auth_device_authorize, + authorization=self._client._anon_token(), + client_id=client_id, + ) + return _device_authorization(_mapping(response_payload(response, 200))) + + def poll_device_token(self, *, client_id: str, device_code: str) -> Session: + """Poll once for an approved device token and commit the session.""" + response = invoke( + self._client._transport.auth_device_token, + authorization=self._client._anon_token(), + client_id=client_id, + device_code=device_code, + ) + session, user = _session_and_user(_mapping(response_payload(response, 200))) + self._replace_auth(session, user) + return session + + def verify_device( + self, + *, + user_code: str, + action: DeviceVerificationAction = "approve", + ) -> DeviceVerification: + """Approve or deny a pending device authorization.""" + if action not in _SUPPORTED_DEVICE_ACTIONS: + raise ValidationError(_INVALID_DEVICE_ACTION) + payload = self._authenticated_payload( + self._client._transport.auth_device_verify, + expected_status=200, + user_code=user_code, + action=action, + ) + return _device_verification(_mapping(payload)) + + def exchange_platform_token(self, *, client_id: str) -> PlatformToken: + """Issue a short-lived platform token for another client.""" + payload = self._authenticated_payload( + self._client._transport.auth_platform_exchange, + expected_status=200, + client_id=client_id, + ) + return _platform_token(_mapping(payload)) + def sign_in(self, *, email: str, password: str) -> Session: """Sign in a user and replace client-owned auth state.""" response = invoke( @@ -837,6 +949,9 @@ def promote_method(self, *, method_id: str) -> AuthMethod: method_id=normalized_method_id, ) method = _auth_method(_mapping(payload)) + current_user = self._client.current_user + if current_user is not None: + self._client._set_user(replace(current_user, email=method.email)) self._refresh_user_best_effort() return method diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index a2883f80..37b91a10 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -23,6 +23,7 @@ ) OAuthProviderName: TypeAlias = Literal["google", "github", "microsoft", "apple"] AuthMethodType: TypeAlias = Literal["password", "oauth", "anonymous"] +DeviceVerificationAction: TypeAlias = Literal["approve", "deny"] class SessionListOptions(TypedDict, total=False): @@ -126,6 +127,50 @@ class AuthorizationRequest: state: str = field(repr=False) +@dataclass(frozen=True, slots=True) +class PasswordPolicy: + """Password rules enforced by the current project.""" + + effective_min_length: int + min_configurable_length: int + max_length: int + require_uppercase: bool + require_lowercase: bool + require_numbers: bool + require_special_chars: bool + compromised_passwords_rejected: bool + + +@dataclass(frozen=True, slots=True) +class DeviceAuthorization: + """RFC 8628 authorization details shown to a device user.""" + + device_code: str = field(repr=False) + user_code: str + verification_uri: str + verification_uri_complete: str = field(repr=False) + expires_in: int + interval: int + + +@dataclass(frozen=True, slots=True) +class DeviceVerification: + """Result of approving or denying a device authorization.""" + + success: bool + status: str + + +@dataclass(frozen=True, slots=True) +class PlatformToken: + """Short-lived platform token issued for another client.""" + + token: str = field(repr=False) + user_id: str + token_id: str + expires_at: datetime + + @dataclass(frozen=True, slots=True) class OAuthProvider: """OAuth provider linked to the current user.""" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 291decd9..63bb7153 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -15,11 +15,15 @@ AuthMethod, AuthorizationRequest, AuthSession, + DeviceAuthorization, + DeviceVerification, EmailChangeResult, MessageResult, OAuthProvider, OAuthProviderName, OAuthTokenResult, + PasswordPolicy, + PlatformToken, Session, SessionPage, SignUpResult, @@ -115,6 +119,21 @@ def auth_get_user(self, **kwargs: Any) -> AuthResponse: def auth_update_user(self, **kwargs: Any) -> AuthResponse: return self._invoke("auth_update_user", kwargs) + def auth_get_password_policy(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_get_password_policy", kwargs) + + def auth_device_authorize(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_device_authorize", kwargs) + + def auth_device_token(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_device_token", kwargs) + + def auth_device_verify(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_device_verify", kwargs) + + def auth_platform_exchange(self, **kwargs: Any) -> AuthResponse: + return self._invoke("auth_platform_exchange", kwargs) + def auth_signup_anonymous(self, **kwargs: Any) -> AuthResponse: return self._invoke("auth_signup_anonymous", kwargs) @@ -232,6 +251,31 @@ def test_public_auth_values_are_frozen_and_slotted() -> None: authorization_url="https://auth.example/authorize?state=oauth-state", state="oauth-state", ), + PasswordPolicy( + effective_min_length=8, + min_configurable_length=6, + max_length=128, + require_uppercase=True, + require_lowercase=True, + require_numbers=True, + require_special_chars=True, + compromised_passwords_rejected=True, + ), + DeviceAuthorization( + "device-secret", + "ABCD-EFGH", + "https://verify.example", + "https://verify.example?code=ABCD-EFGH", + 600, + 5, + ), + DeviceVerification(success=True, status="approved"), + PlatformToken( + token="platform-secret", + user_id="user-123", + token_id="00000000-0000-4000-8000-000000000001", + expires_at=datetime(2026, 8, 28, 13, tzinfo=UTC), + ), OAuthProvider( provider="github", linked_at=datetime(2026, 8, 28, tzinfo=UTC), @@ -300,6 +344,10 @@ def test_public_auth_value_annotations_do_not_expose_generated_models() -> None: MessageResult, EmailChangeResult, AuthorizationRequest, + PasswordPolicy, + DeviceAuthorization, + DeviceVerification, + PlatformToken, OAuthProvider, OAuthTokenResult, AuthSession, @@ -325,11 +373,111 @@ def test_secret_auth_fields_are_absent_from_repr() -> None: authorization_url="https://auth.example/authorize?state=oauth-state", state="oauth-state", ) + device = DeviceAuthorization( + "device-secret", + "ABCD-EFGH", + "https://verify.example", + "https://verify.example?code=ABCD-EFGH", + 600, + 5, + ) + platform = PlatformToken( + token="platform-secret", + user_id="user-123", + token_id="token-id", + expires_at=datetime.now(UTC), + ) assert "access-token" not in repr(session) assert "refresh-token" not in repr(session) assert "email-change-token" not in repr(email_change) assert "oauth-state" not in repr(authorization) + assert "device-secret" not in repr(device) + assert "platform-secret" not in repr(platform) + + +def test_password_device_and_platform_auth_flows() -> None: + transport = AuthTransport() + transport.queue( + "auth_get_password_policy", + AuthResponse( + 200, + { + "effective_min_length": 12, + "min_configurable_length": 8, + "max_length": 128, + "require_uppercase": True, + "require_lowercase": True, + "require_numbers": True, + "require_special_chars": True, + "compromised_passwords_rejected": True, + }, + ), + ) + transport.queue( + "auth_device_authorize", + AuthResponse( + 200, + { + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://verify.example", + "verification_uri_complete": "https://verify.example?code=ABCD-EFGH", + "expires_in": 600, + "interval": 5, + }, + ), + ) + transport.queue( + "auth_device_token", + AuthResponse( + 200, + _token_payload( + access_token="device-access", refresh_token="device-refresh" + ), + ), + ) + transport.queue( + "auth_device_verify", AuthResponse(200, {"success": True, "status": "approved"}) + ) + transport.queue( + "auth_platform_exchange", + AuthResponse( + 200, + { + "token": "platform-secret", + "user_id": "user-123", + "token_id": "00000000-0000-4000-8000-000000000001", + "expires_at": "2026-08-28T13:00:00Z", + }, + ), + ) + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + + policy = client.auth.get_password_policy() + authorization = client.auth.start_device_authorization(client_id="volcano-cli") + session = client.auth.poll_device_token( + client_id="volcano-cli", device_code="device-secret" + ) + verification = client.auth.verify_device(user_code="ABCD-EFGH") + platform = client.auth.exchange_platform_token(client_id="volcano-cli") + + assert policy.effective_min_length == 12 + assert authorization.user_code == "ABCD-EFGH" + assert session is client.current_session + assert verification == DeviceVerification(success=True, status="approved") + assert platform.token_id == "00000000-0000-4000-8000-000000000001" + + +def test_device_verification_rejects_an_unknown_action() -> None: + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=AuthTransport() + ) + + with pytest.raises(ValidationError, match="approve or deny"): + client.auth.verify_device(user_code="ABCD-EFGH", action=cast("Any", "ignore")) def test_oauth_provider_name_accepts_the_supported_providers() -> None: @@ -1340,7 +1488,7 @@ def test_identity_management_returns_public_immutable_values() -> None: "type": "oauth", "provider": "github", "identity_id": identity_payload["id"], - "email": identity_payload["email"], + "email": "primary@example.com", "is_primary": True, "last_used_at": "2026-08-28T12:00:00Z", "created_at": "2026-08-27T12:00:00Z", @@ -1357,7 +1505,7 @@ def test_identity_management_returns_public_immutable_values() -> None: transport.queue("auth_promote_method", AuthResponse(200, method_payload)) transport.queue( "auth_get_user", - AuthResponse(200, {"user": _user_payload(email="primary@example.com")}), + AuthResponse(503, {"error": "Temporarily unavailable"}), ) transport.queue("auth_unlink_identity", AuthResponse(204)) client = VolcanoClient( @@ -1365,6 +1513,7 @@ def test_identity_management_returns_public_immutable_values() -> None: access_token="access-token", _transport=transport, ) + client._set_user(User(id="user-123", email="previous@example.com")) identities = client.auth.list_identities() methods = client.auth.list_methods() @@ -1388,7 +1537,7 @@ def test_identity_management_returns_public_immutable_values() -> None: type="oauth", provider="github", identity_id=identity_id, - email="user@example.com", + email="primary@example.com", is_primary=True, last_used_at=datetime(2026, 8, 28, 12, tzinfo=UTC), created_at=datetime(2026, 8, 27, 12, tzinfo=UTC), diff --git a/tests/unit/test_generated_transport.py b/tests/unit/test_generated_transport.py index 4521b3ea..58340fe5 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -552,3 +552,39 @@ def test_generated_transport_calls_device_session_operations() -> None: "Bearer access-token", "Bearer access-token", ] + + +def test_generated_transport_calls_extended_auth_operations() -> None: + transport, requests = _recording_transport() + + transport.auth_get_password_policy(authorization="anon-key") + transport.auth_device_authorize(authorization="anon-key", client_id="volcano-cli") + transport.auth_device_token( + authorization="anon-key", client_id="volcano-cli", device_code="device-secret" + ) + transport.auth_device_verify( + authorization="access-token", user_code="ABCD-EFGH", action="approve" + ) + transport.auth_platform_exchange( + authorization="access-token", client_id="volcano-cli" + ) + + assert [(request.method, request.url.path) for request in requests] == [ + ("GET", "/auth/password-policy"), + ("POST", "/auth/device/authorize"), + ("POST", "/auth/device/token"), + ("POST", "/auth/device/verify"), + ("POST", "/auth/platform/exchange"), + ] + assert json.loads(requests[2].content) == { + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + "device_code": "device-secret", + "client_id": "volcano-cli", + } + assert [request.headers["authorization"] for request in requests] == [ + "Bearer anon-key", + "Bearer anon-key", + "Bearer anon-key", + "Bearer access-token", + "Bearer access-token", + ] diff --git a/tests/unit/test_import.py b/tests/unit/test_import.py index 0f2224f7..688c3046 100644 --- a/tests/unit/test_import.py +++ b/tests/unit/test_import.py @@ -15,6 +15,9 @@ def test_package_exports_the_public_sdk_contract() -> None: "AuthenticationError", "AuthorizationRequest", "ConflictError", + "DeviceAuthorization", + "DeviceVerification", + "DeviceVerificationAction", "EmailChangeResult", "JSONValue", "LockLease", @@ -23,6 +26,8 @@ def test_package_exports_the_public_sdk_contract() -> None: "OAuthProvider", "OAuthProviderName", "OAuthTokenResult", + "PasswordPolicy", + "PlatformToken", "RateLimitedError", "ServerError", "Session", From 844d19e21ccf98df9852742bd36d229d989b84b1 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:27:48 -0400 Subject: [PATCH 29/37] fix(realtime): fence publishes across auth changes --- src/volcano_sdk/realtime.py | 28 ++++++++++++++++++++----- tests/unit/test_realtime.py | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 8546c387..2c1ed580 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -7,6 +7,7 @@ import inspect import logging from collections.abc import Awaitable, Callable +from threading import Lock from typing import Any, Protocol, cast from urllib.parse import quote, urlsplit, urlunsplit @@ -213,6 +214,7 @@ def __init__(self, realtime: Realtime, name: str) -> None: self._name = name self._message_callbacks: list[MessageCallback] = [] self._subscription: CentrifugeSubscription | None = None + self._subscription_auth_generation: int | None = None self._callback_queue: asyncio.Queue[tuple[int, Any]] = asyncio.Queue( maxsize=CALLBACK_QUEUE_LIMIT ) @@ -331,6 +333,7 @@ async def _reset(self) -> None: def _invalidate_authentication(self) -> None: self._auth_generation += 1 self._subscription = None + self._subscription_auth_generation = None if ( self._active_callback_task is not None and self._active_callback_task is not _current_task() @@ -343,6 +346,7 @@ def _invalidate_authentication(self) -> None: def _discard_closed_loop_authentication(self) -> None: self._auth_generation += 1 self._subscription = None + self._subscription_auth_generation = None self._callback_task = None self._active_callback_task = None self._callback_stop = None @@ -367,6 +371,7 @@ def __init__( self._connection_lock = asyncio.Lock() self._loop: asyncio.AbstractEventLoop | None = None self._auth_generation = 0 + self._auth_generation_lock = Lock() self._channels: dict[str, Channel] = {} self._auth_cleanup_tasks: set[asyncio.Task[None]] = set() self._in_flight_publishes: set[asyncio.Task[Any]] = set() @@ -396,7 +401,7 @@ async def _connect_locked(self) -> CentrifugeConnection: return self._connection self._loop = asyncio.get_running_loop() while self._connection is None: - generation = self._auth_generation + generation = self._auth_generation_snapshot() connection = _VolcanoCentrifugeConnection( self._client_factory( self._address(), @@ -405,7 +410,7 @@ async def _connect_locked(self) -> CentrifugeConnection: ) ) await connection.connect() - if generation == self._auth_generation: + if generation == self._auth_generation_snapshot(): self._connection = connection else: await self._close_invalidated(connection) @@ -426,6 +431,7 @@ async def _subscribe_locked(self, channel: Channel) -> None: events=_ChannelEvents(channel, generation), ) channel._subscription = subscription + channel._subscription_auth_generation = self._auth_generation_snapshot() if await self._subscribe_current_generation( channel, subscription, generation ): @@ -458,8 +464,13 @@ def _subscription_is_current( ) async def _publish(self, channel: Channel, data: Any) -> None: + generation = self._auth_generation_snapshot() async with self._connection_lock: - if channel._subscription is None: + if ( + generation != self._auth_generation_snapshot() + or channel._subscription is None + or channel._subscription_auth_generation != generation + ): raise RuntimeError(CHANNEL_NOT_SUBSCRIBED) task = asyncio.current_task() if task is not None: @@ -490,6 +501,7 @@ async def disconnect(self) -> None: def on_auth_change(self) -> None: """Immediately invalidate work authenticated by the previous session.""" + self._advance_auth_generation() loop = self._loop if loop is not None and loop.is_closed(): self._discard_closed_loop_authentication() @@ -509,7 +521,6 @@ def _schedule_auth_invalidation( self._discard_closed_loop_authentication() def _invalidate_authentication(self) -> None: - self._auth_generation += 1 connection = self._connection self._connection = None channels = tuple(self._channels.values()) @@ -524,7 +535,6 @@ def _invalidate_authentication(self) -> None: task.add_done_callback(self._auth_cleanup_tasks.discard) def _discard_closed_loop_authentication(self) -> None: - self._auth_generation += 1 self._connection = None self._connection_lock = asyncio.Lock() self._loop = None @@ -533,6 +543,14 @@ def _discard_closed_loop_authentication(self) -> None: for channel in tuple(self._channels.values()): channel._discard_closed_loop_authentication() + def _advance_auth_generation(self) -> None: + with self._auth_generation_lock: + self._auth_generation += 1 + + def _auth_generation_snapshot(self) -> int: + with self._auth_generation_lock: + return self._auth_generation + async def _close_invalidated( self, connection: CentrifugeConnection, diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 632de4ac..8223d605 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -408,6 +408,48 @@ async def scenario() -> None: assert outcomes == [True] +def test_worker_auth_change_invalidates_a_publish_queued_before_loop_cleanup() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + completed = Event() + workers: list[Thread] = [] + + def replace_auth() -> None: + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + completed.set() + + def listener(user: Any | None) -> None: + if user is None or workers: + return + worker = Thread(target=replace_auth) + workers.append(worker) + worker.start() + assert completed.wait(1) + + async def scenario() -> None: + channel = client.realtime.channel("contract") + await channel.subscribe() + await client.realtime._connection_lock.acquire() + publishing = asyncio.create_task(channel.send({"value": "old-session"})) + await asyncio.sleep(0) + client.realtime._connection_lock.release() + client.auth.on_auth_state_change(listener) + with pytest.raises(RuntimeError, match="subscribed"): + await publishing + await client.realtime.disconnect() + + asyncio.run(scenario()) + assert official.subscription is not None + assert ("publish", {"value": "old-session"}) not in official.subscription.calls + + def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: transport = AuthTransport() official = FakeCentrifugeClient() From cb49343c2ea10360f0fee609bfe29f5c3b266535 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:37:46 -0400 Subject: [PATCH 30/37] fix(auth): align device verification contract --- src/volcano_sdk/auth.py | 4 ++-- src/volcano_sdk/models.py | 4 ++-- tests/unit/test_auth.py | 10 ++++++++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index ccde8d0e..6370670d 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -316,8 +316,8 @@ def _device_authorization(payload: Mapping[str, Any]) -> DeviceAuthorization: def _device_verification(payload: Mapping[str, Any]) -> DeviceVerification: return DeviceVerification( - success=_required_bool(payload, "success"), - status=_required_text(payload, "status"), + success=_optional_bool(payload.get("success")), + status=_optional_text(payload.get("status")), ) diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 37b91a10..1ee6f1d3 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -157,8 +157,8 @@ class DeviceAuthorization: class DeviceVerification: """Result of approving or denying a device authorization.""" - success: bool - status: str + success: bool | None = None + status: str | None = None @dataclass(frozen=True, slots=True) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 63bb7153..22d35d27 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -480,6 +480,16 @@ def test_device_verification_rejects_an_unknown_action() -> None: client.auth.verify_device(user_code="ABCD-EFGH", action=cast("Any", "ignore")) +def test_device_verification_accepts_omitted_response_metadata() -> None: + transport = AuthTransport() + transport.queue("auth_device_verify", AuthResponse(200, {})) + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + + assert client.auth.verify_device(user_code="ABCD-EFGH") == DeviceVerification() + + def test_oauth_provider_name_accepts_the_supported_providers() -> None: providers: tuple[OAuthProviderName, ...] = ( "google", From 118bb00b21619d89d5cbbfd4894ec040b74db27a Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:46:16 -0400 Subject: [PATCH 31/37] docs(auth): clarify platform exchange eligibility --- docs/authentication.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index 27e5f07d..4fa9eb02 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -105,11 +105,13 @@ session = device_client.auth.poll_device_token( ) ``` -Signed-in clients can also exchange their session for a short-lived platform -token. Treat `token.token` as a secret: +After the device client commits its approved device-flow session, it can +exchange that verified session for a short-lived platform token. Ordinary +email/password and OAuth sessions are not eligible. Treat `token.token` as a +secret: ```python -token = client.auth.exchange_platform_token(client_id="volcano-cli") +token = device_client.auth.exchange_platform_token(client_id="volcano-cli") ``` ## Create and update accounts From c92c2e60058d59dd901e1f4ff43fd85eda4ed9aa Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:49:37 -0400 Subject: [PATCH 32/37] fix(auth): close concurrency review gaps --- src/volcano_sdk/auth.py | 27 +++++++-------- src/volcano_sdk/realtime.py | 21 +++++++++--- tests/unit/test_auth.py | 66 +++++++++++++++++++++++++++++++++++++ tests/unit/test_realtime.py | 45 +++++++++++++++++++++++++ 4 files changed, 141 insertions(+), 18 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 6370670d..8b83e3c2 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -812,7 +812,7 @@ def get_linked_oauth_providers(self) -> tuple[OAuthProvider, ...]: expected_status=200, ) ) - providers_value = payload.get("providers") + providers_value = payload.get("providers", []) if not isinstance(providers_value, list): raise AuthenticationError(_INVALID_AUTH_RESPONSE) providers = cast("list[object]", providers_value) @@ -942,18 +942,19 @@ def list_methods(self) -> tuple[AuthMethod, ...]: def promote_method(self, *, method_id: str) -> AuthMethod: """Make a sign-in method the account's primary method.""" - normalized_method_id = _validated_uuid(method_id, _INVALID_METHOD_ID) - payload = self._authenticated_payload( - self._client._transport.auth_promote_method, - expected_status=200, - method_id=normalized_method_id, - ) - method = _auth_method(_mapping(payload)) - current_user = self._client.current_user - if current_user is not None: - self._client._set_user(replace(current_user, email=method.email)) - self._refresh_user_best_effort() - return method + with self._operation(): + normalized_method_id = _validated_uuid(method_id, _INVALID_METHOD_ID) + payload = self._authenticated_payload( + self._client._transport.auth_promote_method, + expected_status=200, + method_id=normalized_method_id, + ) + method = _auth_method(_mapping(payload)) + current_user = self._client.current_user + if current_user is not None: + self._client._set_user(replace(current_user, email=method.email)) + self._refresh_user_best_effort() + return method def get_sessions( self, diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 2c1ed580..81a045ee 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -178,12 +178,22 @@ def new_subscription( class _ChannelEvents: - def __init__(self, channel: Channel, generation: int) -> None: + def __init__( + self, + channel: Channel, + channel_generation: int, + auth_generation: int, + ) -> None: self._channel = channel - self._generation = generation + self._channel_generation = channel_generation + self._auth_generation = auth_generation async def on_publication(self, ctx: PublicationContext) -> None: - if self._generation == self._channel._auth_generation: + if ( + self._channel_generation == self._channel._auth_generation + and self._auth_generation + == self._channel._realtime._auth_generation_snapshot() + ): await self._channel._emit(ctx.pub.data) async def on_subscribing(self, ctx: Any) -> None: @@ -426,12 +436,13 @@ async def _subscribe_locked(self, channel: Channel) -> None: subscription = channel._subscription if subscription is None: connection = await self._connect_locked() + auth_generation = self._auth_generation_snapshot() subscription = connection.new_subscription( channel._name, - events=_ChannelEvents(channel, generation), + events=_ChannelEvents(channel, generation, auth_generation), ) channel._subscription = subscription - channel._subscription_auth_generation = self._auth_generation_snapshot() + channel._subscription_auth_generation = auth_generation if await self._subscribe_current_generation( channel, subscription, generation ): diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 22d35d27..6882f7c2 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1573,6 +1573,72 @@ def test_identity_management_rejects_malformed_ids_before_transport() -> None: assert transport.calls == [] +def test_promotion_cache_update_is_serialized_with_sign_out( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + method = { + "id": "7f518a4b-407b-4121-907b-d72a2c7c1ac6", + "type": "oauth", + "provider": "github", + "identity_id": "3cd3e058-e3ff-42a5-ae4d-650ef9b45746", + "email": "primary@example.com", + "is_primary": True, + "created_at": "2026-08-27T12:00:00Z", + "updated_at": "2026-08-28T12:00:00Z", + } + transport.queue("auth_promote_method", AuthResponse(200, method)) + transport.queue("auth_get_user", AuthResponse(503, {"error": "unavailable"})) + transport.queue("auth_logout", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + access_token="access-token", + refresh_token="refresh-token", + _transport=transport, + ) + client._set_user(User(id="user-123", email="previous@example.com")) + entered, release, sign_out_started, sign_out_completed = (Event() for _ in range(4)) + original_set_user = client._set_user + + def blocked_set_user(user: User) -> None: + entered.set() + assert release.wait(1) + original_set_user(user) + + monkeypatch.setattr(client, "_set_user", blocked_set_user) + promotion = Thread( + target=client.auth.promote_method, kwargs={"method_id": method["id"]} + ) + promotion.start() + assert entered.wait(1) + + def sign_out() -> None: + sign_out_started.set() + client.auth.sign_out() + sign_out_completed.set() + + signing_out = Thread(target=sign_out) + signing_out.start() + assert sign_out_started.wait(1) + assert not sign_out_completed.wait(0.1) + release.set() + promotion.join(timeout=1) + signing_out.join(timeout=1) + + assert client.current_session is None + assert client.current_user is None + + +def test_omitted_oauth_provider_list_is_empty() -> None: + transport = AuthTransport() + transport.queue("auth_list_oauth_providers", AuthResponse(200, {})) + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + + assert client.auth.get_linked_oauth_providers() == () + + def test_provider_and_device_session_flows_return_public_values() -> None: transport = AuthTransport() transport.queue("auth_unlink_oauth_provider", AuthResponse(204)) diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 8223d605..e95507de 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -450,6 +450,51 @@ async def scenario() -> None: assert ("publish", {"value": "old-session"}) not in official.subscription.calls +def test_worker_auth_change_fences_a_publication_queued_before_loop_cleanup() -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + completed = Event() + workers: list[Thread] = [] + + def replace_auth() -> None: + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + completed.set() + + def listener(user: Any | None) -> None: + if user is None or workers: + return + worker = Thread(target=replace_auth) + workers.append(worker) + worker.start() + assert completed.wait(1) + + async def scenario() -> None: + received: list[str] = [] + channel = client.realtime.channel("contract").on( + "message", lambda data: received.append(data["value"]) + ) + await channel.subscribe() + emitting = asyncio.create_task( + official.emit_wire_publication( + "broadcast:contract", {"value": "old-session"} + ) + ) + client.auth.on_auth_state_change(listener) + await emitting + await asyncio.sleep(0) + assert received == [] + await client.realtime.disconnect() + + asyncio.run(scenario()) + + def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: transport = AuthTransport() official = FakeCentrifugeClient() From f501c6d23124367772ace1815d75eaaeef36c798 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:06:22 -0400 Subject: [PATCH 33/37] fix(auth): fence stale asynchronous state --- src/volcano_sdk/client.py | 14 ++++- src/volcano_sdk/realtime.py | 118 +++++++++++++++++++++++++++++------- tests/unit/test_auth.py | 24 ++++++++ tests/unit/test_realtime.py | 56 ++++++++++++++++- 4 files changed, 187 insertions(+), 25 deletions(-) diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 101e2ee8..7f97bab5 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -38,6 +38,8 @@ def __init__(self, callback: Callable[[User | None], None]) -> None: self.callback = callback self.pending: deque[User | None] = deque() self.dispatching = False + self.subscribed = True + self.callback_running = False class _AuthNotificationState(local): @@ -178,7 +180,10 @@ def _subscribe_auth( def unsubscribe() -> None: with self._auth_state_lock: - self._auth_listeners.pop(listener_id, None) + if self._auth_listeners.pop(listener_id, None) is registration: + registration.subscribed = False + if registration.callback_running: + registration.pending.clear() return unsubscribe @@ -195,6 +200,8 @@ def _queue_auth_listener( listener: _AuthListener, current_user: User | None, ) -> bool: + if not listener.subscribed: + return False listener.pending.append(current_user) if listener.dispatching: return False @@ -228,7 +235,12 @@ def _drain_auth_listener(self, listener: _AuthListener) -> None: listener.dispatching = False return current_user = listener.pending.popleft() + listener.callback_running = True self._invoke_auth_listener(listener.callback, current_user) + with self._auth_state_lock: + listener.callback_running = False + if not listener.subscribed: + listener.pending.clear() def _invoke_auth_listener( self, diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 81a045ee..c384422e 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -225,11 +225,12 @@ def __init__(self, realtime: Realtime, name: str) -> None: self._message_callbacks: list[MessageCallback] = [] self._subscription: CentrifugeSubscription | None = None self._subscription_auth_generation: int | None = None - self._callback_queue: asyncio.Queue[tuple[int, Any]] = asyncio.Queue( + self._callback_queue: asyncio.Queue[tuple[int, int, Any]] = asyncio.Queue( maxsize=CALLBACK_QUEUE_LIMIT ) self._callback_task: asyncio.Task[None] | None = None self._active_callback_task: asyncio.Task[None] | None = None + self._active_callback_auth_generation: int | None = None self._callback_stop: asyncio.Event | None = None self._auth_generation = 0 @@ -263,7 +264,13 @@ async def _emit(self, data: Any) -> None: ) self._callback_stop = None try: - self._callback_queue.put_nowait((self._auth_generation, data)) + self._callback_queue.put_nowait( + ( + self._auth_generation, + self._realtime._auth_generation_snapshot(), + data, + ) + ) except asyncio.QueueFull: asyncio.get_running_loop().call_exception_handler( { @@ -285,10 +292,15 @@ async def _restart_callback_dispatcher(self, previous: asyncio.Task[None]) -> No async def _dispatch_callbacks(self, stop: asyncio.Event) -> None: while not stop.is_set(): - generation, data = await self._callback_queue.get() + generation, auth_generation, data = await self._callback_queue.get() try: for callback in tuple(self._message_callbacks): - if not await self._dispatch_callback(callback, data, generation): + if not await self._dispatch_callback( + callback, + data, + generation, + auth_generation, + ): break finally: self._callback_queue.task_done() @@ -298,16 +310,25 @@ async def _dispatch_callback( callback: MessageCallback, data: Any, generation: int, + auth_generation: int, ) -> bool: - if generation != self._auth_generation: + if ( + generation != self._auth_generation + or auth_generation != self._realtime._auth_generation_snapshot() + ): return False active_task = asyncio.create_task(self._run_callback(callback, data)) self._active_callback_task = active_task + self._active_callback_auth_generation = auth_generation try: (error,) = await asyncio.gather(active_task, return_exceptions=True) finally: self._active_callback_task = None - if generation != self._auth_generation: + self._active_callback_auth_generation = None + if ( + generation != self._auth_generation + or auth_generation != self._realtime._auth_generation_snapshot() + ): return False if isinstance(error, BaseException): asyncio.get_running_loop().call_exception_handler( @@ -353,12 +374,42 @@ def _invalidate_authentication(self) -> None: self._callback_queue.get_nowait() self._callback_queue.task_done() + def _invalidate_authentication_before(self, auth_generation: int) -> None: + self._discard_callbacks_before(auth_generation) + subscription_generation = self._subscription_auth_generation + if ( + subscription_generation is not None + and subscription_generation >= auth_generation + ): + return + self._invalidate_authentication() + + def _discard_callbacks_before(self, auth_generation: int) -> None: + active_generation = self._active_callback_auth_generation + active_task = self._active_callback_task + if ( + active_generation is not None + and active_generation < auth_generation + and active_task is not None + and active_task is not _current_task() + ): + active_task.cancel() + current_items: list[tuple[int, int, Any]] = [] + while not self._callback_queue.empty(): + item = self._callback_queue.get_nowait() + self._callback_queue.task_done() + if item[1] >= auth_generation: + current_items.append(item) + for item in current_items: + self._callback_queue.put_nowait(item) + def _discard_closed_loop_authentication(self) -> None: self._auth_generation += 1 self._subscription = None self._subscription_auth_generation = None self._callback_task = None self._active_callback_task = None + self._active_callback_auth_generation = None self._callback_stop = None self._callback_queue = asyncio.Queue(maxsize=CALLBACK_QUEUE_LIMIT) @@ -378,13 +429,14 @@ def __init__( self._api_url = api_url self._client_factory = client_factory self._connection: CentrifugeConnection | None = None + self._connection_auth_generation: int | None = None self._connection_lock = asyncio.Lock() self._loop: asyncio.AbstractEventLoop | None = None self._auth_generation = 0 self._auth_generation_lock = Lock() self._channels: dict[str, Channel] = {} self._auth_cleanup_tasks: set[asyncio.Task[None]] = set() - self._in_flight_publishes: set[asyncio.Task[Any]] = set() + self._in_flight_publishes: dict[asyncio.Task[Any], int] = {} def channel(self, name: str) -> Channel: """Return a stable channel facade for a broadcast name.""" @@ -407,8 +459,17 @@ async def _connect(self) -> CentrifugeConnection: return await self._connect_locked() async def _connect_locked(self) -> CentrifugeConnection: - if self._connection is not None: + generation = self._auth_generation_snapshot() + if ( + self._connection is not None + and self._connection_auth_generation == generation + ): return self._connection + if self._connection is not None: + stale_connection = self._connection + self._connection = None + self._connection_auth_generation = None + await self._close_invalidated(stale_connection) self._loop = asyncio.get_running_loop() while self._connection is None: generation = self._auth_generation_snapshot() @@ -422,6 +483,7 @@ async def _connect_locked(self) -> CentrifugeConnection: await connection.connect() if generation == self._auth_generation_snapshot(): self._connection = connection + self._connection_auth_generation = generation else: await self._close_invalidated(connection) return self._connection @@ -459,12 +521,12 @@ async def _subscribe_current_generation( try: await subscription.subscribe() except Exception: - if generation == channel._auth_generation: + if self._subscription_is_current(channel, subscription, generation): raise return self._subscription_is_current(channel, subscription, generation) - @staticmethod def _subscription_is_current( + self, channel: Channel, subscription: CentrifugeSubscription, generation: int, @@ -472,6 +534,8 @@ def _subscription_is_current( return ( generation == channel._auth_generation and channel._subscription is subscription + and channel._subscription_auth_generation + == self._auth_generation_snapshot() ) async def _publish(self, channel: Channel, data: Any) -> None: @@ -485,12 +549,12 @@ async def _publish(self, channel: Channel, data: Any) -> None: raise RuntimeError(CHANNEL_NOT_SUBSCRIBED) task = asyncio.current_task() if task is not None: - self._in_flight_publishes.add(task) + self._in_flight_publishes[task] = generation try: await channel._subscription.publish(data) finally: if task is not None: - self._in_flight_publishes.discard(task) + self._in_flight_publishes.pop(task, None) async def _unsubscribe(self, channel: Channel) -> None: async with self._connection_lock: @@ -502,6 +566,7 @@ async def disconnect(self) -> None: async with self._connection_lock: connection = self._connection self._connection = None + self._connection_auth_generation = None try: if connection is not None: await connection.disconnect() @@ -512,33 +577,40 @@ async def disconnect(self) -> None: def on_auth_change(self) -> None: """Immediately invalidate work authenticated by the previous session.""" - self._advance_auth_generation() + auth_generation = self._advance_auth_generation() loop = self._loop if loop is not None and loop.is_closed(): self._discard_closed_loop_authentication() return if loop is not None and _running_loop() is not loop: - self._schedule_auth_invalidation(loop) + self._schedule_auth_invalidation(loop, auth_generation) return - self._invalidate_authentication() + self._invalidate_authentication(auth_generation) def _schedule_auth_invalidation( self, loop: asyncio.AbstractEventLoop, + auth_generation: int, ) -> None: try: - loop.call_soon_threadsafe(self._invalidate_authentication) + loop.call_soon_threadsafe(self._invalidate_authentication, auth_generation) except RuntimeError: self._discard_closed_loop_authentication() - def _invalidate_authentication(self) -> None: + def _invalidate_authentication(self, auth_generation: int) -> None: connection = self._connection - self._connection = None + connection_generation = self._connection_auth_generation + if connection_generation is None or connection_generation < auth_generation: + self._connection = None + self._connection_auth_generation = None + else: + connection = None channels = tuple(self._channels.values()) - for task in tuple(self._in_flight_publishes): - task.cancel() + for task, generation in tuple(self._in_flight_publishes.items()): + if generation < auth_generation: + task.cancel() for channel in channels: - channel._invalidate_authentication() + channel._invalidate_authentication_before(auth_generation) if connection is None or self._loop is None: return task = self._loop.create_task(self._close_invalidated(connection)) @@ -547,6 +619,7 @@ def _invalidate_authentication(self) -> None: def _discard_closed_loop_authentication(self) -> None: self._connection = None + self._connection_auth_generation = None self._connection_lock = asyncio.Lock() self._loop = None self._auth_cleanup_tasks.clear() @@ -554,9 +627,10 @@ def _discard_closed_loop_authentication(self) -> None: for channel in tuple(self._channels.values()): channel._discard_closed_loop_authentication() - def _advance_auth_generation(self) -> None: + def _advance_auth_generation(self) -> int: with self._auth_generation_lock: self._auth_generation += 1 + return self._auth_generation def _auth_generation_snapshot(self) -> int: with self._auth_generation_lock: diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 6882f7c2..84b3a6a3 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1057,6 +1057,30 @@ def listener(current_user: User | None) -> None: assert observations == [user, None] +def test_unsubscribe_discards_notifications_queued_during_callback() -> None: + client = VolcanoClient(anon_key="anon-key", access_token="access-token") + entered = Event() + release = Event() + observations: list[User | None] = [] + + def listener(current_user: User | None) -> None: + observations.append(current_user) + entered.set() + assert release.wait(timeout=1) + + unsubscribe = client.auth.on_auth_state_change(listener) + user = User(id="user-123", email="user@example.com") + notifying = Thread(target=client._set_user, args=(user,)) + notifying.start() + assert entered.wait(timeout=1) + client._clear_auth() + unsubscribe() + release.set() + notifying.join(timeout=1) + + assert observations == [user] + + def test_restored_session_listener_waits_for_user_hydration() -> None: transport = AuthTransport() transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index e95507de..7b76e45d 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -165,6 +165,17 @@ async def emit_wire_publication(self, name: str, data: Any) -> None: await subscription.emit(data) +def _assert_publish_uses_new_connection( + first: FakeCentrifugeClient, + second: FakeCentrifugeClient, +) -> None: + assert first.subscription is not None + assert second.subscription is not None + publish = ("publish", {"value": "new-session"}) + assert publish not in first.subscription.calls + assert publish in second.subscription.calls + + class LoopBoundFakeCentrifugeClient(FakeCentrifugeClient): def __init__(self) -> None: super().__init__() @@ -277,8 +288,7 @@ def test_realtime_wraps_official_client_without_exposing_it() -> None: def test_auth_replacement_invalidates_the_connected_realtime_identity() -> None: transport = AuthTransport() - first = FakeCentrifugeClient() - second = FakeCentrifugeClient() + first, second = FakeCentrifugeClient(), FakeCentrifugeClient() clients = iter((first, second)) def factory(*args: Any, **kwargs: Any) -> FakeCentrifugeClient: @@ -495,6 +505,48 @@ async def scenario() -> None: asyncio.run(scenario()) +def test_worker_auth_change_replaces_connection_before_queued_cleanup() -> None: + transport = AuthTransport() + first, second = FakeCentrifugeClient(), FakeCentrifugeClient() + clients = iter((first, second)) + completed = Event() + workers: list[Thread] = [] + + def factory(*args: Any, **kwargs: Any) -> FakeCentrifugeClient: + del args, kwargs + return next(clients) + + client = VolcanoClient( + anon_key="anon-key", _transport=transport, _realtime_client_factory=factory + ) + client.auth.sign_in(email="user@example.com", password="secret") + + def replace_auth() -> None: + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + completed.set() + + def listener(user: Any | None) -> None: + if user is None or workers: + return + worker = Thread(target=replace_auth) + workers.append(worker) + worker.start() + assert completed.wait(1) + + async def scenario() -> None: + await client.realtime.channel("old").subscribe() + next_channel = client.realtime.channel("next") + subscribing = asyncio.create_task(next_channel.subscribe()) + client.auth.on_auth_state_change(listener) + await subscribing + await next_channel.send({"value": "new-session"}) + await client.realtime.disconnect() + + asyncio.run(scenario()) + _assert_publish_uses_new_connection(first, second) + + def test_auth_change_discards_state_owned_by_a_closed_realtime_loop() -> None: transport = AuthTransport() official = FakeCentrifugeClient() From dfaa12ac3e5dc417b3d37a1e238846c4bdb27eb2 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:15:59 -0400 Subject: [PATCH 34/37] fix(auth): complete realtime cleanup barriers --- src/volcano_sdk/auth.py | 17 ++++--- src/volcano_sdk/realtime.py | 67 ++++++++++++++++++--------- tests/unit/test_auth.py | 16 +++++++ tests/unit/test_realtime.py | 90 ++++++++++++++++++++++++++++++++++++- 4 files changed, 162 insertions(+), 28 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index 8b83e3c2..a316ce72 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -189,8 +189,11 @@ def _message(payload: Mapping[str, Any]) -> MessageResult: return MessageResult(message=message) -def _oauth_token(payload: Mapping[str, Any]) -> OAuthTokenResult: - provider_value = payload.get("provider") +def _oauth_token( + payload: Mapping[str, Any], + provider: OAuthProviderName, +) -> OAuthTokenResult: + provider_value = payload.get("provider", provider) if not isinstance(provider_value, str): raise AuthenticationError(_INVALID_AUTH_RESPONSE) return OAuthTokenResult( @@ -831,12 +834,13 @@ def refresh_oauth_token( provider: OAuthProviderName, ) -> OAuthTokenResult: """Refresh the stored access token for an OAuth provider.""" + normalized_provider = _provider(provider) payload = self._authenticated_payload( self._client._transport.refresh_oauth_provider_token, expected_status=200, - provider=_provider(provider), + provider=normalized_provider, ) - return _oauth_token(_mapping(payload)) + return _oauth_token(_mapping(payload), normalized_provider) def get_oauth_provider_token( self, @@ -844,12 +848,13 @@ def get_oauth_provider_token( provider: OAuthProviderName, ) -> OAuthTokenResult: """Get metadata for the current OAuth provider token.""" + normalized_provider = _provider(provider) payload = self._authenticated_payload( self._client._transport.get_oauth_provider_token, expected_status=200, - provider=_provider(provider), + provider=normalized_provider, ) - return _oauth_token(_mapping(payload)) + return _oauth_token(_mapping(payload), normalized_provider) def call_oauth_api( self, diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index c384422e..9735366f 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -460,34 +460,53 @@ async def _connect(self) -> CentrifugeConnection: async def _connect_locked(self) -> CentrifugeConnection: generation = self._auth_generation_snapshot() - if ( - self._connection is not None - and self._connection_auth_generation == generation - ): - return self._connection - if self._connection is not None: - stale_connection = self._connection - self._connection = None - self._connection_auth_generation = None - await self._close_invalidated(stale_connection) + existing_connection = await self._connection_for_generation(generation) + if existing_connection is not None: + return existing_connection self._loop = asyncio.get_running_loop() while self._connection is None: generation = self._auth_generation_snapshot() - connection = _VolcanoCentrifugeConnection( - self._client_factory( - self._address(), - token=self._client_context._session_token(), - get_token=self._token, - ) - ) - await connection.connect() - if generation == self._auth_generation_snapshot(): + connection = await self._open_connection(generation) + if connection is not None: self._connection = connection self._connection_auth_generation = generation - else: - await self._close_invalidated(connection) return self._connection + async def _connection_for_generation( + self, + generation: int, + ) -> CentrifugeConnection | None: + connection = self._connection + if connection is None or self._connection_auth_generation == generation: + return connection + self._connection = None + self._connection_auth_generation = None + await self._close_invalidated(connection) + return None + + async def _open_connection( + self, + generation: int, + ) -> CentrifugeConnection | None: + connection = _VolcanoCentrifugeConnection( + self._client_factory( + self._address(), + token=self._client_context._session_token(), + get_token=self._token, + ) + ) + try: + await connection.connect() + except Exception: + if generation == self._auth_generation_snapshot(): + raise + await self._close_invalidated(connection) + return None + if generation == self._auth_generation_snapshot(): + return connection + await self._close_invalidated(connection) + return None + async def _subscribe(self, channel: Channel) -> None: async with self._connection_lock: await self._subscribe_locked(channel) @@ -571,10 +590,16 @@ async def disconnect(self) -> None: if connection is not None: await connection.disconnect() finally: + await self._await_auth_cleanup() for channel in tuple(self._channels.values()): await channel._reset() self._loop = None + async def _await_auth_cleanup(self) -> None: + while self._auth_cleanup_tasks: + tasks = tuple(self._auth_cleanup_tasks) + await asyncio.gather(*tasks, return_exceptions=True) + def on_auth_change(self) -> None: """Immediately invalidate work authenticated by the previous session.""" auth_generation = self._advance_auth_generation() diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 84b3a6a3..6ca4da89 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1663,6 +1663,22 @@ def test_omitted_oauth_provider_list_is_empty() -> None: assert client.auth.get_linked_oauth_providers() == () +def test_oauth_token_responses_may_omit_the_requested_provider() -> None: + transport = AuthTransport() + payload = {"expires_in": 3600, "message": "Ready"} + transport.queue("refresh_oauth_provider_token", AuthResponse(200, payload)) + transport.queue("get_oauth_provider_token", AuthResponse(200, payload)) + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + + refreshed = client.auth.refresh_oauth_token(provider="github") + current = client.auth.get_oauth_provider_token(provider="github") + + assert refreshed.provider == "github" + assert current.provider == "github" + + def test_provider_and_device_session_flows_return_public_values() -> None: transport = AuthTransport() transport.queue("auth_unlink_oauth_provider", AuthResponse(204)) diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 7b76e45d..d63e91af 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -12,7 +12,9 @@ from volcano_sdk import VolcanoClient if TYPE_CHECKING: - from collections.abc import Awaitable, Callable + from collections.abc import Awaitable, Callable, Iterator + + from volcano_sdk.realtime import CentrifugeFactory UNEXPECTED_TRANSPORT_CALL = "unexpected transport operation" @@ -176,6 +178,21 @@ def _assert_publish_uses_new_connection( assert publish in second.subscription.calls +def _sequential_factory( + clients: Iterator[FakeCentrifugeClient], +) -> CentrifugeFactory: + def factory( + address: str, + *, + token: str, + get_token: Callable[[], Awaitable[str]], + ) -> FakeCentrifugeClient: + del address, token, get_token + return next(clients) + + return factory + + class LoopBoundFakeCentrifugeClient(FakeCentrifugeClient): def __init__(self) -> None: super().__init__() @@ -859,6 +876,42 @@ async def scenario() -> None: assert second.calls == ["connect", "disconnect"] +def test_auth_change_retries_an_obsolete_failed_realtime_connect( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + first, second = FakeCentrifugeClient(), FakeCentrifugeClient() + entered, release = asyncio.Event(), asyncio.Event() + clients = iter((first, second)) + + async def connect() -> None: + entered.set() + await release.wait() + message = "old credentials rejected" + raise RuntimeError(message) + + monkeypatch.setattr(first, "connect", connect) + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=_sequential_factory(clients), + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + connecting = asyncio.create_task(client.realtime._connect()) + await entered.wait() + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + release.set() + assert await connecting is not first + await client.realtime.disconnect() + + asyncio.run(scenario()) + assert first.calls == ["disconnect"] + assert second.calls == ["connect", "disconnect"] + + def test_auth_change_cancels_an_in_flight_realtime_publish( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1076,6 +1129,41 @@ async def scenario() -> None: assert official.calls == ["connect", "channel:broadcast:contract", "disconnect"] +def test_realtime_disconnect_waits_for_auth_cleanup( + monkeypatch: pytest.MonkeyPatch, +) -> None: + transport = AuthTransport() + official = FakeCentrifugeClient() + entered, release = asyncio.Event(), asyncio.Event() + + async def disconnect() -> None: + entered.set() + await release.wait() + official.calls.append("disconnect") + + monkeypatch.setattr(official, "disconnect", disconnect) + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + + async def scenario() -> None: + await client.realtime.channel("contract").subscribe() + transport.access_token = "access-2" + client.auth.sign_in(email="next@example.com", password="secret") + await entered.wait() + disconnecting = asyncio.create_task(client.realtime.disconnect()) + await asyncio.sleep(0) + assert not disconnecting.done() + release.set() + await disconnecting + + asyncio.run(scenario()) + assert official.calls[-1] == "disconnect" + + def test_realtime_disconnect_resets_channels_after_transport_failure( monkeypatch: pytest.MonkeyPatch, ) -> None: From 9c743df7000a92bacc83008a590f41258b799233 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:22:22 -0400 Subject: [PATCH 35/37] fix(realtime): drain completed cleanup snapshots --- src/volcano_sdk/realtime.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 9735366f..7fa322a1 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -599,6 +599,7 @@ async def _await_auth_cleanup(self) -> None: while self._auth_cleanup_tasks: tasks = tuple(self._auth_cleanup_tasks) await asyncio.gather(*tasks, return_exceptions=True) + self._auth_cleanup_tasks.difference_update(tasks) def on_auth_change(self) -> None: """Immediately invalidate work authenticated by the previous session.""" From dfb2a2b2875fa655985087ff3c525d679f40aa6d Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:34:55 -0400 Subject: [PATCH 36/37] fix(auth): reconcile validated response state --- src/volcano_sdk/auth.py | 47 ++++++++++++++++------- src/volcano_sdk/client.py | 6 +++ tests/unit/test_auth.py | 80 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 14 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index a316ce72..d2f1a723 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -108,6 +108,8 @@ def _commit_auth(self, session: Session, user: User) -> None: ... def _set_user(self, user: User) -> None: ... + def _clear_user(self) -> None: ... + def _clear_auth(self) -> None: ... def _subscribe_auth( @@ -238,7 +240,7 @@ def _auth_session(payload: Mapping[str, Any]) -> AuthSession: def _required_text(payload: Mapping[str, Any], key: str) -> str: value = payload.get(key) - if not isinstance(value, str): + if not isinstance(value, str) or not value: raise AuthenticationError(_INVALID_AUTH_RESPONSE) return value @@ -361,9 +363,11 @@ def _session_and_user(payload: Mapping[str, Any]) -> tuple[Session, User]: if not isinstance(access_token, str) or not access_token: raise AuthenticationError(_INVALID_AUTH_RESPONSE) refresh_token_value = payload.get("refresh_token") - refresh_token = ( - refresh_token_value if isinstance(refresh_token_value, str) else None - ) + if refresh_token_value is not None and ( + not isinstance(refresh_token_value, str) or not refresh_token_value + ): + raise AuthenticationError(_INVALID_AUTH_RESPONSE) + refresh_token = refresh_token_value user = _user(_mapping(payload.get("user"))) return ( Session( @@ -530,7 +534,7 @@ def update_user( *, password: str | None = None, user_metadata: Mapping[str, JSONValue] | None = None, - ) -> User: + ) -> User | None: """Update the current user's password or metadata.""" with self._operation(): payload = _mapping( @@ -541,9 +545,7 @@ def update_user( user_metadata=user_metadata, ) ) - user = _user(_mapping(payload.get("user"))) - self._client._set_user(user) - return user + return self._reconcile_user_payload(payload) def refresh_session(self) -> Session: """Rotate the current refresh token and replace local auth state.""" @@ -630,7 +632,7 @@ def confirm_email(self, *, token: str) -> MessageResult: token=token, ) result = _message(_mapping(response_payload(response, 200))) - self._refresh_user_best_effort() + self._refresh_user_or_clear() return result def resend_confirmation(self, *, email: str) -> MessageResult: @@ -670,6 +672,20 @@ def _refresh_user_best_effort(self) -> None: with suppress(VolcanoError): self.get_user() + def _refresh_user_or_clear(self) -> User | None: + try: + return self.get_user() + except VolcanoError: + self._client._clear_user() + return None + + def _reconcile_user_payload(self, payload: Mapping[str, Any]) -> User | None: + if "user" not in payload: + return self._refresh_user_or_clear() + user = _user(_mapping(payload["user"])) + self._client._set_user(user) + return user + def request_email_change(self, *, new_email: str) -> EmailChangeResult: """Request a change to the current user's email address.""" payload = _mapping( @@ -801,11 +817,14 @@ def link_oauth_provider( def unlink_oauth_provider(self, *, provider: OAuthProviderName) -> None: """Unlink an OAuth provider from the current user.""" - self._authenticated_payload( - self._client._transport.auth_unlink_oauth_provider, - expected_status=204, - provider=_provider(provider), - ) + with self._operation(): + self._authenticated_payload( + self._client._transport.auth_unlink_oauth_provider, + expected_status=204, + provider=_provider(provider), + ) + if self._client.current_user is not None: + self._refresh_user_or_clear() def get_linked_oauth_providers(self) -> tuple[OAuthProvider, ...]: """Return OAuth providers linked to the current user.""" diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index 7f97bab5..fce16e9e 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -151,6 +151,12 @@ def _set_user(self, user: User) -> None: listeners = self._queue_auth_notifications() self._notify_auth_listeners(listeners) + def _clear_user(self) -> None: + with self._auth_state_lock: + self._current_user = None + listeners = self._queue_auth_notifications() + self._notify_auth_listeners(listeners) + def _clear_auth(self) -> None: with self._auth_state_lock: self._current_session = None diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 6ca4da89..71f9e0b8 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -471,6 +471,27 @@ def test_password_device_and_platform_auth_flows() -> None: assert platform.token_id == "00000000-0000-4000-8000-000000000001" +def test_platform_exchange_rejects_empty_required_text() -> None: + transport = AuthTransport() + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + payload = { + "token": "platform-secret", + "user_id": "user-123", + "token_id": "token-id", + "expires_at": "2026-08-28T13:00:00Z", + } + + for field in ("token", "user_id", "token_id"): + transport.queue( + "auth_platform_exchange", + AuthResponse(200, payload | {field: ""}), + ) + with pytest.raises(AuthenticationError, match="missing required fields"): + client.auth.exchange_platform_token(client_id="volcano-cli") + + def test_device_verification_rejects_an_unknown_action() -> None: client = VolcanoClient( anon_key="anon-key", access_token="access-token", _transport=AuthTransport() @@ -735,6 +756,7 @@ def test_get_and_update_user_preserve_the_current_session() -> None: user_metadata={"display_name": "Updated"}, ) + assert updated is not None assert fetched.email == "user@example.com" assert updated.email == "updated@example.com" assert client.current_user is updated @@ -752,6 +774,24 @@ def test_get_and_update_user_preserve_the_current_session() -> None: ] +def test_update_user_hydrates_an_omitted_response_user() -> None: + transport = AuthTransport() + transport.queue("auth_update_user", AuthResponse(200, {})) + transport.queue( + "auth_get_user", + AuthResponse(200, {"user": _user_payload(email="updated@example.com")}), + ) + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + + updated = client.auth.update_user(user_metadata={"plan": "pro"}) + + assert updated is not None + assert updated.email == "updated@example.com" + assert client.current_user is updated + + def test_authenticated_facade_normalizes_a_missing_session() -> None: client = VolcanoClient(anon_key="anon-key", _transport=AuthTransport()) @@ -1357,10 +1397,35 @@ def test_confirm_email_preserves_success_when_user_refresh_fails() -> None: refresh_token="refresh-token", _transport=transport, ) + client._set_user(User(id="user-123", email="stale@example.com")) result = client.auth.confirm_email(token="confirmation-token") assert result == MessageResult(message="Done") + assert client.current_user is None + + +def test_token_responses_reject_unusable_optional_refresh_tokens() -> None: + transport = AuthTransport() + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + for refresh_token in ("", 7): + payload = _token_payload( + access_token="access-token", + refresh_token="refresh-token", + ) + payload["refresh_token"] = refresh_token + transport.queue( + "auth_signin", + AuthResponse(200, payload), + ) + with pytest.raises( + AuthenticationError, + match="missing required fields", + ): + client.auth.sign_in(email="user@example.com", password="secret") + + assert client.current_session is None def test_hosted_and_oauth_authorization_urls_bind_caller_state( @@ -1794,6 +1859,21 @@ def test_provider_and_device_session_flows_return_public_values() -> None: ) +def test_unlink_oauth_provider_reconciles_the_cached_user() -> None: + transport = AuthTransport() + transport.queue("auth_unlink_oauth_provider", AuthResponse(204)) + transport.queue("auth_get_user", AuthResponse(200, {"user": _user_payload()})) + client = VolcanoClient( + anon_key="anon-key", access_token="access-token", _transport=transport + ) + client._set_user(User(id="user-123", email="previous@example.com")) + + client.auth.unlink_oauth_provider(provider="github") + + assert client.current_user is not None + assert client.current_user.email == "user@example.com" + + def test_session_listing_exposes_filters_and_cursor_navigation() -> None: transport = AuthTransport() transport.queue( From 3a9cbc065e6194d55332811a2d6c6e20475451a4 Mon Sep 17 00:00:00 2001 From: Sean Keever <33592180+swkeever@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:47:16 -0400 Subject: [PATCH 37/37] fix(auth): preserve listener semantics --- src/volcano_sdk/auth.py | 9 +++------ src/volcano_sdk/client.py | 4 +--- src/volcano_sdk/models.py | 2 +- tests/unit/test_auth.py | 14 ++++++++++++++ 4 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/volcano_sdk/auth.py b/src/volcano_sdk/auth.py index d2f1a723..71706a6a 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -108,7 +108,7 @@ def _commit_auth(self, session: Session, user: User) -> None: ... def _set_user(self, user: User) -> None: ... - def _clear_user(self) -> None: ... + def _invalidate_user(self) -> None: ... def _clear_auth(self) -> None: ... @@ -185,10 +185,7 @@ def _provider_not_linked(error: AuthenticationError) -> bool: def _message(payload: Mapping[str, Any]) -> MessageResult: - message = payload.get("message") - if not isinstance(message, str): - raise AuthenticationError(_INVALID_AUTH_RESPONSE) - return MessageResult(message=message) + return MessageResult(message=_optional_text(payload.get("message"))) def _oauth_token( @@ -676,7 +673,7 @@ def _refresh_user_or_clear(self) -> User | None: try: return self.get_user() except VolcanoError: - self._client._clear_user() + self._client._invalidate_user() return None def _reconcile_user_payload(self, payload: Mapping[str, Any]) -> User | None: diff --git a/src/volcano_sdk/client.py b/src/volcano_sdk/client.py index fce16e9e..8efb9ba2 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -151,11 +151,9 @@ def _set_user(self, user: User) -> None: listeners = self._queue_auth_notifications() self._notify_auth_listeners(listeners) - def _clear_user(self) -> None: + def _invalidate_user(self) -> None: with self._auth_state_lock: self._current_user = None - listeners = self._queue_auth_notifications() - self._notify_auth_listeners(listeners) def _clear_auth(self) -> None: with self._auth_state_lock: diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 1ee6f1d3..0153c607 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -107,7 +107,7 @@ class SignUpResult: class MessageResult: """Acknowledgement returned by an authentication operation.""" - message: str + message: str | None @dataclass(frozen=True, slots=True) diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 71f9e0b8..64ee2afb 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -1398,11 +1398,25 @@ def test_confirm_email_preserves_success_when_user_refresh_fails() -> None: _transport=transport, ) client._set_user(User(id="user-123", email="stale@example.com")) + observations: list[User | None] = [] + client.auth.on_auth_state_change(observations.append) + observations.clear() result = client.auth.confirm_email(token="confirmation-token") assert result == MessageResult(message="Done") assert client.current_user is None + assert observations == [] + + +def test_acknowledgements_accept_an_omitted_message() -> None: + transport = AuthTransport() + transport.queue("auth_reset_password", AuthResponse(200, {})) + client = VolcanoClient(anon_key="anon-key", _transport=transport) + + result = client.auth.reset_password(token="recovery-token", new_password="next") + + assert result == MessageResult(message=None) def test_token_responses_reject_unusable_optional_refresh_tokens() -> None: