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..4fa9eb02 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,255 @@ +# 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_in) +``` + +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 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( + 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(sort="created_at", status="active", 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() +``` + +## 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, +) +``` + +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 = device_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. + +```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 +``` + +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 +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() +``` + +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 +retain before navigation: + +```python +request = client.auth.get_hosted_auth_url( + project_id="project-id", + action="login", +) +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. + +```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") +``` + +## 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/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..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" @@ -105,6 +100,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 +115,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..27e080b7 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,264 @@ 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) + 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) + + @given("an authenticated client") def authenticated_client(context: Any) -> None: _world(context).authenticate() diff --git a/pyproject.toml b/pyproject.toml index b39884e7..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", ] @@ -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/__init__.py b/src/volcano_sdk/__init__.py index ab9a77cb..d3e0d0ce 100644 --- a/src/volcano_sdk/__init__.py +++ b/src/volcano_sdk/__init__.py @@ -11,17 +11,60 @@ ValidationError, VolcanoError, ) -from .models import LockLease, Session +from .models import ( + AuthIdentity, + AuthMethod, + AuthMethodType, + AuthorizationRequest, + AuthSession, + DeviceAuthorization, + DeviceVerification, + DeviceVerificationAction, + EmailChangeResult, + JSONValue, + LockLease, + MessageResult, + OAuthProvider, + OAuthProviderName, + OAuthTokenResult, + PasswordPolicy, + PlatformToken, + Session, + SessionListOptions, + SessionPage, + SignUpResult, + User, +) __all__ = [ + "AuthIdentity", + "AuthMethod", + "AuthMethodType", + "AuthSession", "AuthenticationError", + "AuthorizationRequest", "ConflictError", + "DeviceAuthorization", + "DeviceVerification", + "DeviceVerificationAction", + "EmailChangeResult", + "JSONValue", "LockLease", + "MessageResult", "NotFoundError", + "OAuthProvider", + "OAuthProviderName", + "OAuthTokenResult", + "PasswordPolicy", + "PlatformToken", "RateLimitedError", "ServerError", "Session", + "SessionListOptions", + "SessionPage", + "SignUpResult", "TransportError", + "User", "ValidationError", "VolcanoClient", "VolcanoError", diff --git a/src/volcano_sdk/_transport.py b/src/volcano_sdk/_transport.py index d46bfb50..97bfde23 100644 --- a/src/volcano_sdk/_transport.py +++ b/src/volcano_sdk/_transport.py @@ -3,29 +3,92 @@ from __future__ import annotations import json +from collections.abc import Generator, Mapping +from contextlib import contextmanager 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, Unpack, cast +from urllib.parse import quote 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_password_policy, + auth_get_user, + auth_list_identities, + auth_list_methods, + auth_logout, + auth_promote_method, + auth_refresh, + auth_request_email_change, + auth_resend_confirmation, + auth_reset_password, + auth_signin, + auth_signup, + auth_signup_anonymous, + auth_unlink_identity, + 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_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, +) 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_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, +) +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.database_select_request import DatabaseSelectRequest from ._generated.models.project_lock_lease_request import ProjectLockLeaseRequest 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, @@ -38,7 +101,23 @@ ) if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable + + from .models import ( + DeviceVerificationAction, + JSONValue, + OAuthProviderName, + SessionListOptions, + ) + + +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 @@ -54,6 +133,7 @@ 422: ValidationError, HTTP_RATE_LIMITED: RateLimitedError, } +_INVALID_AUTH_RESPONSE = "Invalid authentication response" class TransportResponse(Protocol): @@ -79,6 +159,51 @@ 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, + *, + authorization: str, + email: str, + password: str, + user_metadata: Mapping[str, JSONValue] | None = None, + ) -> TransportResponse: ... + def auth_signin( self, *, @@ -87,6 +212,198 @@ 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: Mapping[str, JSONValue] | None = None, + ) -> TransportResponse: ... + + def auth_signup_anonymous( + self, + *, + authorization: str, + user_metadata: Mapping[str, JSONValue] | None = None, + ) -> TransportResponse: ... + + def auth_convert_anonymous( + self, + *, + authorization: str, + email: str, + password: str, + user_metadata: Mapping[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 | None = None, + limit: int = 20, + **options: Unpack[SessionListOptions], + ) -> TransportResponse: ... + + def auth_delete_my_session( + self, + *, + authorization: str, + session_id: str, + ) -> TransportResponse: ... + + def auth_delete_all_my_sessions( + self, + *, + 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, *, @@ -210,6 +527,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 @@ -236,13 +564,480 @@ 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), ) 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, + *, + authorization: str, + email: str, + password: str, + 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"] = _mutable_json(user_metadata) + 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( + self, + *, + authorization: str, + refresh_token: str, + ) -> TransportResponse: + with self._auth_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._auth_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._auth_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: 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"] = _mutable_json(user_metadata) + with self._auth_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: Mapping[str, JSONValue] | None = None, + ) -> TransportResponse: + body_data = ( + {"user_metadata": _mutable_json(user_metadata)} + if user_metadata is not None + else {} + ) + with self._auth_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: 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"] = _mutable_json(user_metadata) + with self._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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._auth_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"] = _mutable_json(body) + with self._auth_client(authorization) as client: + response = client.get_httpx_client().post( + f"/auth/oauth/{quote(provider, safe='')}/call-api", + json=body_data, + ) + 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, + *, + authorization: str, + page: int | None = None, + limit: int = 20, + **options: Unpack[SessionListOptions], + ) -> TransportResponse: + 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, + 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) + + def auth_delete_my_session( + self, + *, + authorization: str, + session_id: str, + ) -> TransportResponse: + with self._auth_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._auth_client(authorization) as client: + 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 c4dcaa7e..71706a6a 100644 --- a/src/volcano_sdk/auth.py +++ b/src/volcano_sdk/auth.py @@ -2,42 +2,1127 @@ from __future__ import annotations -from typing import Protocol +import binascii +import json +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 +from threading import RLock +from typing import TYPE_CHECKING, Any, Literal, Protocol, Unpack, cast +from urllib.parse import quote, urlencode +from uuid import UUID -from ._transport import Transport, invoke, response_payload -from .models import Session +from ._transport import Transport, TransportResponse, invoke, response_payload +from .errors import AuthenticationError, ValidationError, VolcanoError +from .models import ( + AuthIdentity, + AuthMethod, + AuthMethodType, + AuthorizationRequest, + AuthSession, + DeviceAuthorization, + DeviceVerification, + DeviceVerificationAction, + EmailChangeResult, + MessageResult, + OAuthProvider, + OAuthProviderName, + OAuthTokenResult, + PasswordPolicy, + PlatformToken, + Session, + SessionListOptions, + SessionPage, + 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 +_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" +_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: + 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 + + +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.""" _transport: Transport + _api_url: str + + @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 _invalidate_user(self) -> None: ... + + def _clear_auth(self) -> None: ... + + def _subscribe_auth( + self, + 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): + 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 _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 _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: + return MessageResult(message=_optional_text(payload.get("message"))) + + +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( + 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 _required_text(payload: Mapping[str, Any], key: str) -> str: + value = payload.get(key) + if not isinstance(value, str) or not value: + 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_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: + 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 _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=_optional_bool(payload.get("success")), + status=_optional_text(payload.get("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): + 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") + 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( + 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 + 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, + *, + email: str, + password: str, + user_metadata: Mapping[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") + 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( + confirmation_required=False, + message=message, + user=self._client.current_user, + session=session, + ) + return SignUpResult( + confirmation_required=confirmation_required, + 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 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"], + session, user = _session_and_user(_mapping(response_payload(response, 200))) + self._replace_auth(session, user) + return session + + def sign_out(self) -> None: + """Revoke the refresh token and always clear local auth state.""" + with self._operation(): + 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.""" + with self._operation(): + 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: Mapping[str, JSONValue] | None = None, + ) -> User | None: + """Update the current user's password or metadata.""" + with self._operation(): + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_update_user, + expected_status=200, + password=password, + user_metadata=user_metadata, + ) + ) + return self._reconcile_user_payload(payload) + + def refresh_session(self) -> Session: + """Rotate the current refresh token and replace local auth state.""" + with self._operation(): + return self._refresh_session() + + def _refresh_session(self) -> Session: + session = self._client.current_session + if session is None: + raise AuthenticationError(_MISSING_AUTH_STATE) + if session.refresh_token is None: + self._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._replace_auth(refreshed, user, preserve_device_sessions=True) + succeeded = True + return refreshed + finally: + if not succeeded: + self._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 sign_up_anonymous( + self, + *, + user_metadata: Mapping[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, ) - self._client._set_session(session) + session, user = _session_and_user(_mapping(response_payload(response, 201))) + self._replace_auth(session, user) return session + + def convert_anonymous( + self, + *, + email: str, + password: str, + user_metadata: Mapping[str, JSONValue] | None = None, + ) -> User: + """Convert the current anonymous user to an email account.""" + with self._operation(): + 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 + + 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, + ) + result = _message(_mapping(response_payload(response, 200))) + self._refresh_user_or_clear() + return result + + 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, + ) + result = _message(_mapping(response_payload(response, 200))) + self._refresh_user_best_effort() + return result + + def _refresh_user_best_effort(self) -> None: + with self._operation(): + if self._client.current_session is None: + return + with suppress(VolcanoError): + self.get_user() + + def _refresh_user_or_clear(self) -> User | None: + try: + return self.get_user() + except VolcanoError: + self._client._invalidate_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( + 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.""" + with self._operation(): + 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.""" + 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, 307) + 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.encode(), expected_state.encode()): + 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._replace_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.""" + 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.""" + 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.""" + normalized_provider = _provider(provider) + payload = self._authenticated_payload( + self._client._transport.refresh_oauth_provider_token, + expected_status=200, + provider=normalized_provider, + ) + return _oauth_token(_mapping(payload), normalized_provider) + + def get_oauth_provider_token( + self, + *, + 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=normalized_provider, + ) + return _oauth_token(_mapping(payload), normalized_provider) + + 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.""" + with self._operation(): + arguments = { + "provider": _provider(provider), + "endpoint": endpoint, + "method": method, + "body": body, + } + 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, + expected_status=200, + retry_unauthorized=False, + **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.""" + normalized_identity_id = _validated_uuid(identity_id, _INVALID_IDENTITY_ID) + self._authenticated_payload( + self._client._transport.auth_unlink_identity, + expected_status=204, + identity_id=normalized_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.""" + 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, + *, + page: int | None = None, + limit: int = 20, + **options: Unpack[SessionListOptions], + ) -> SessionPage: + """Return a page of the current user's device sessions.""" + with self._operation(): + payload = _mapping( + self._authenticated_payload( + self._client._transport.auth_get_my_sessions, + expected_status=200, + page=page, + limit=limit, + **options, + ) + ) + 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")), + 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: + """Delete one device session.""" + with self._operation(): + 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 + or normalized_session_id == _token_session_id(self._session_token()) + ) + 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.""" + 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], + *, + expected_status: int, + retry_unauthorized: bool = True, + **kwargs: object, + ) -> object: + with self._operation(): + 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: + response = invoke( + operation, + authorization=self._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 not retry_unauthorized + ): + raise + if session.refresh_token is None: + self._clear_auth() + raise + self.refresh_session() + response = invoke( + operation, + authorization=self._session_token(), + **kwargs, + ) + try: + return response_payload(response, expected_status) + except AuthenticationError as error: + if error.status == _HTTP_UNAUTHORIZED: + 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, + user: User, + *, + preserve_device_sessions: bool = False, + ) -> None: + 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(): + 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 33c18ad9..8efb9ba2 100644 --- a/src/volcano_sdk/client.py +++ b/src/volcano_sdk/client.py @@ -2,20 +2,50 @@ from __future__ import annotations -from typing import TYPE_CHECKING +import logging +from collections import deque +from contextlib import suppress +from threading import RLock, local +from typing import TYPE_CHECKING, TypedDict, Unpack, cast 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 + 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__) +_AUTH_BOOTSTRAP_KEYS = frozenset({"access_token", "refresh_token"}) + + +class _AuthBootstrap(TypedDict, total=False): + access_token: str | None + 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 + self.subscribed = True + self.callback_running = False + + +class _AuthNotificationState(local): + def __init__(self) -> None: + self.depth = 0 + self.listeners: list[_AuthListener] = [] class VolcanoClient: @@ -28,16 +58,35 @@ 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: """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 + 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: + 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._auth_state_lock = RLock() + self._auth_listeners: dict[int, _AuthListener] = {} + self._next_auth_listener_id = 0 + self._auth_notification_state = _AuthNotificationState() self._transport: Transport = ( - _transport + cast("Transport", _transport) if _transport is not None else GeneratedTransport(api_url=self._api_url, timeout=timeout) ) @@ -56,7 +105,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.""" + with self._auth_state_lock: + return self._current_user def database(self, name: str) -> Database: """Create a query facade for a project database.""" @@ -66,9 +122,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: @@ -76,4 +133,127 @@ 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: + with self._auth_state_lock: + self._current_session = session + self._current_user = user + 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 + listeners = self._queue_auth_notifications() + self._notify_auth_listeners(listeners) + + def _invalidate_user(self) -> None: + with self._auth_state_lock: + self._current_user = None + + def _clear_auth(self) -> None: + with self._auth_state_lock: + self._current_session = None + self._current_user = None + listeners = self._queue_auth_notifications() + self.realtime.on_auth_change() + self._notify_auth_listeners(listeners) + + def _subscribe_auth( + self, + listener: Callable[[User | None], None], + ) -> Callable[[], None]: + with self._auth_state_lock: + listener_id = self._next_auth_listener_id + self._next_auth_listener_id += 1 + registration = _AuthListener(listener) + self._auth_listeners[listener_id] = registration + notify_immediately = ( + self._current_session is None or self._current_user is not None + ) + 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: + if self._auth_listeners.pop(listener_id, None) is registration: + registration.subscribed = False + if registration.callback_running: + registration.pending.clear() + + return unsubscribe + + 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: + if not listener.subscribed: + return False + listener.pending.append(current_user) + if listener.dispatching: + return False + listener.dispatching = True + 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: + if not listener.pending: + 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, + listener: Callable[[User | None], None], + current_user: User | None, + ) -> None: + completed = False + with suppress(Exception): + listener(current_user) + completed = True + if not completed: + _LOGGER.error(_AUTH_LISTENER_FAILED) diff --git a/src/volcano_sdk/models.py b/src/volcano_sdk/models.py index 8ff7275b..0153c607 100644 --- a/src/volcano_sdk/models.py +++ b/src/volcano_sdk/models.py @@ -2,20 +2,250 @@ from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING +from collections.abc import Mapping +from dataclasses import dataclass, field +from types import MappingProxyType +from typing import TYPE_CHECKING, Literal, TypeAlias, TypedDict if TYPE_CHECKING: from datetime import datetime +JSONValue: TypeAlias = ( + str + | int + | float + | bool + | list["JSONValue"] + | tuple["JSONValue", ...] + | dict[str, "JSONValue"] + | Mapping[str, "JSONValue"] + | None +) +OAuthProviderName: TypeAlias = Literal["google", "github", "microsoft", "apple"] +AuthMethodType: TypeAlias = Literal["password", "oauth", "anonymous"] +DeviceVerificationAction: TypeAlias = Literal["approve", "deny"] + + +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( + {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.""" + + id: str + email: str + project_id: str | None = None + email_confirmed: bool | None = None + 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 + last_sign_in_at: datetime | None = None + 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: """Authenticated user session.""" - access_token: str - refresh_token: str + access_token: str = field(repr=False) + refresh_token: str | None = field(default=None, repr=False) + user_id: str | None = None + expires_in: int | 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 | None + + +@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 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 | None = None + status: str | None = None + + +@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.""" + + 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 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.""" + + 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 + has_more: bool | None = None + next_cursor: str | None = None + prev_cursor: str | None = None @dataclass(frozen=True, slots=True) diff --git a/src/volcano_sdk/realtime.py b/src/volcano_sdk/realtime.py index 780bbbf3..7fa322a1 100644 --- a/src/volcano_sdk/realtime.py +++ b/src/volcano_sdk/realtime.py @@ -5,10 +5,14 @@ import asyncio import importlib 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 +from typing_extensions import override + MessageCallback = Callable[[Any], Any] CALLBACK_QUEUE_LIMIT = 128 CALLBACK_QUEUE_FULL_MESSAGE = ( @@ -18,6 +22,21 @@ SUBSCRIPTION_REGISTRY_UNAVAILABLE = ( "centrifuge client subscription registry is unavailable" ) +_LOGGER = logging.getLogger(__name__) + + +def _current_task() -> asyncio.Task[Any] | None: + try: + return asyncio.current_task() + except RuntimeError: + return None + + +def _running_loop() -> asyncio.AbstractEventLoop | None: + try: + return asyncio.get_running_loop() + except RuntimeError: + return None class RealtimeContext(Protocol): @@ -120,6 +139,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: @@ -158,11 +178,23 @@ def new_subscription( class _ChannelEvents: - def __init__(self, channel: Channel) -> None: + def __init__( + self, + channel: Channel, + channel_generation: int, + auth_generation: int, + ) -> None: self._channel = channel + self._channel_generation = channel_generation + self._auth_generation = auth_generation async def on_publication(self, ctx: PublicationContext) -> None: - await self._channel._emit(ctx.pub.data) + 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: del ctx @@ -192,12 +224,15 @@ 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._subscription_auth_generation: int | None = None + 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 def on(self, event: str, callback: MessageCallback) -> Channel: """Register a callback for broadcast messages.""" @@ -229,7 +264,13 @@ 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, + self._realtime._auth_generation_snapshot(), + data, + ) + ) except asyncio.QueueFull: asyncio.get_running_loop().call_exception_handler( { @@ -251,37 +292,61 @@ 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, auth_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, + auth_generation, + ): + break finally: self._callback_queue.task_done() + async def _dispatch_callback( + self, + callback: MessageCallback, + data: Any, + generation: int, + auth_generation: int, + ) -> bool: + 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 + 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( + { + "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): 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: @@ -296,6 +361,58 @@ 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 + self._subscription_auth_generation = 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() + 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) + class Realtime: """Manage project realtime connections and channels.""" @@ -312,8 +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: dict[asyncio.Task[Any], int] = {} def channel(self, name: str) -> Channel: """Return a stable channel facade for a broadcast name.""" @@ -336,8 +459,35 @@ async def _connect(self) -> CentrifugeConnection: return await self._connect_locked() async def _connect_locked(self) -> CentrifugeConnection: - if self._connection is not None: - return self._connection + generation = self._auth_generation_snapshot() + 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 = await self._open_connection(generation) + if connection is not None: + self._connection = connection + self._connection_auth_generation = generation + 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(), @@ -345,25 +495,85 @@ async def _connect_locked(self) -> CentrifugeConnection: get_token=self._token, ) ) - await connection.connect() - self._connection = connection - return connection + 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: - connection = await self._connect_locked() - if channel._subscription is None: - channel._subscription = connection.new_subscription( + await self._subscribe_locked(channel) + + async def _subscribe_locked(self, channel: Channel) -> None: + while True: + generation = channel._auth_generation + 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), + events=_ChannelEvents(channel, generation, auth_generation), ) - await channel._subscription.subscribe() + channel._subscription = subscription + channel._subscription_auth_generation = auth_generation + 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 self._subscription_is_current(channel, subscription, generation): + raise + return self._subscription_is_current(channel, subscription, generation) + + def _subscription_is_current( + self, + channel: Channel, + subscription: CentrifugeSubscription, + generation: int, + ) -> bool: + 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: + 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) - await channel._subscription.publish(data) + task = asyncio.current_task() + if task is not None: + self._in_flight_publishes[task] = generation + try: + await channel._subscription.publish(data) + finally: + if task is not None: + self._in_flight_publishes.pop(task, None) async def _unsubscribe(self, channel: Channel) -> None: async with self._connection_lock: @@ -375,9 +585,88 @@ 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() 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) + self._auth_cleanup_tasks.difference_update(tasks) + + def on_auth_change(self) -> None: + """Immediately invalidate work authenticated by the previous session.""" + 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, auth_generation) + return + 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, auth_generation) + except RuntimeError: + self._discard_closed_loop_authentication() + + def _invalidate_authentication(self, auth_generation: int) -> None: + connection = self._connection + 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, generation in tuple(self._in_flight_publishes.items()): + if generation < auth_generation: + task.cancel() + for channel in channels: + 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)) + self._auth_cleanup_tasks.add(task) + task.add_done_callback(self._auth_cleanup_tasks.discard) + + 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() + self._in_flight_publishes.clear() + for channel in tuple(self._channels.values()): + channel._discard_closed_loop_authentication() + + 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: + return self._auth_generation + + 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 new file mode 100644 index 00000000..64ee2afb --- /dev/null +++ b/tests/unit/test_auth.py @@ -0,0 +1,2328 @@ +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 +from typing import TYPE_CHECKING, Any, cast + +import pytest +from typing_extensions import override + +from volcano_sdk import ( + AuthIdentity, + AuthMethod, + AuthorizationRequest, + AuthSession, + DeviceAuthorization, + DeviceVerification, + EmailChangeResult, + MessageResult, + OAuthProvider, + OAuthProviderName, + OAuthTokenResult, + PasswordPolicy, + PlatformToken, + Session, + 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", + email_confirmed: bool = True, +) -> dict[str, Any]: + return { + "id": "user-123", + "email": email, + "project_id": "project-123", + "email_confirmed": email_confirmed, + "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), + } + + +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]]] = [] + 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 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) + + 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 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) + + 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) + + +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") + 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", + ), + 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), + ), + OAuthTokenResult( + provider="github", + 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", + 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_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, + Session, + SignUpResult, + MessageResult, + EmailChangeResult, + AuthorizationRequest, + PasswordPolicy, + DeviceAuthorization, + DeviceVerification, + PlatformToken, + 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", + ) + 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_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() + ) + + with pytest.raises(ValidationError, match="approve or deny"): + 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", + "github", + "microsoft", + "apple", + ) + + 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"}, + }, + ) + ] + + +@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( + "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_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()})) + 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 updated is not None + 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_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()) + + 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( + "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_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_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_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)) + 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)] + + +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_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_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()})) + 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( + "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()}), + ) + 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", + "auth_reset_password", + "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( + 200, + { + "message": "Check your email", + "new_email": "new@example.com", + "email_change_token": "development-token", + }, + ), + ) + 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"}) + converted = client.auth.convert_anonymous( + email="user@example.com", + password="secret", + user_metadata={"plan": "developer"}, + ) + messages = ( + client.auth.resend_confirmation(email="user@example.com"), + client.auth.forgot_password(email="user@example.com"), + ) + 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 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( + 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 password_reset == MessageResult(message="Done") + assert client.current_session is not None + 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_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_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_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"})) + 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, + ) + 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: + 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( + 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( + 307, + 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_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_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": "primary@example.com", + "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_get_user", + AuthResponse(503, {"error": "Temporarily unavailable"}), + ) + transport.queue("auth_unlink_identity", AuthResponse(204)) + client = VolcanoClient( + anon_key="anon-key", + 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() + 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="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), + 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_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_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)) + 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": "3cd3e058-e3ff-42a5-ae4d-650ef9b45746", + "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, + }, + ), + 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)) + 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.get_sessions(page=2, limit=20) + client.auth.delete_all_other_sessions() + client.auth.delete_session(session_id="3cd3e058-e3ff-42a5-ae4d-650ef9b45746") + + 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="3cd3e058-e3ff-42a5-ae4d-650ef9b45746", + 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, + ) + + +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( + "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() + 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_uses_structured_code_to_preserve_session() -> None: + transport = AuthTransport() + transport.queue( + "call_oauth_provider_api", + 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 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" + + +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_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( + "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_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", + 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") + + +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() + 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 + + +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" + + +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_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", 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_generated_transport.py b/tests/unit/test_generated_transport.py index 30e5e4b3..58340fe5 100644 --- a/tests/unit/test_generated_transport.py +++ b/tests/unit/test_generated_transport.py @@ -3,73 +3,138 @@ import json import httpx +import pytest -from volcano_sdk._transport import GeneratedTransport +from volcano_sdk import AuthenticationError, User, VolcanoClient +from volcano_sdk._transport import GeneratedTransport, TransportResponse -def test_generated_transport_calls_the_six_openapi_operations() -> None: +def _recording_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}" + return httpx.Response(418) + + return ( + GeneratedTransport( + api_url="https://api.test.volcano.dev", + httpx_transport=httpx.MockTransport(handle), + ), + requests, + ) + + +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 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, + 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) + 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", @@ -106,13 +171,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", @@ -146,3 +208,383 @@ 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_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() + + 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_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() + + 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_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_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() + + transport.auth_get_my_sessions( + authorization="access-token", + limit=10, + sort="created_at", + status="active", + cursor="next-page", + offset=20, + ) + 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) == { + "limit": "10", + "sort": "created_at", + "status": "active", + "cursor": "next-page", + "offset": "20", + } + assert [request.headers["authorization"] for request in requests] == [ + "Bearer access-token", + "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 478ebd90..688c3046 100644 --- a/tests/unit/test_import.py +++ b/tests/unit/test_import.py @@ -1,5 +1,42 @@ +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__ == [ + "AuthIdentity", + "AuthMethod", + "AuthMethodType", + "AuthSession", + "AuthenticationError", + "AuthorizationRequest", + "ConflictError", + "DeviceAuthorization", + "DeviceVerification", + "DeviceVerificationAction", + "EmailChangeResult", + "JSONValue", + "LockLease", + "MessageResult", + "NotFoundError", + "OAuthProvider", + "OAuthProviderName", + "OAuthTokenResult", + "PasswordPolicy", + "PlatformToken", + "RateLimitedError", + "ServerError", + "Session", + "SessionListOptions", + "SessionPage", + "SignUpResult", + "TransportError", + "User", + "ValidationError", + "VolcanoClient", + "VolcanoError", + ] diff --git a/tests/unit/test_realtime.py b/tests/unit/test_realtime.py index 9556ffed..d63e91af 100644 --- a/tests/unit/test_realtime.py +++ b/tests/unit/test_realtime.py @@ -1,16 +1,20 @@ from __future__ import annotations import asyncio -from dataclasses import dataclass +from dataclasses import dataclass, field +from threading import Event, Thread from types import SimpleNamespace from typing import TYPE_CHECKING, Any import pytest +from typing_extensions import override 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" @@ -40,7 +44,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", + }, }, ) @@ -117,6 +124,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] = [] @@ -141,6 +167,48 @@ 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 + + +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__() + 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 @@ -156,24 +224,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", @@ -182,43 +285,393 @@ 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) + ) + + assert factory.address == ( + "wss://api.test.volcano.dev/realtime/v1/websocket?apikey=anon%20key" + ) + assert factory.token == "access-1" + assert official.calls == ["connect", "channel:broadcast:contract", "disconnect"] + assert official.subscription is not None + assert official.subscription.calls == [ + ("subscribe", None), + ("publish", {"event": "message", "value": "contract"}), + ("unsubscribe", None), + ] + assert received == [{"event": "message", "value": "contract"}] + + +def test_auth_replacement_invalidates_the_connected_realtime_identity() -> None: + transport = AuthTransport() + first, second = FakeCentrifugeClient(), 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") - assert channel.on("message", received.append) is channel + received: list[str] = [] + channel = client.realtime.channel("contract").on( + "message", lambda data: received.append(data["value"]) + ) await channel.subscribe() - assert official.subscription is not None - await official.emit_wire_publication( - "project-id:broadcast:contract", - {"event": "message", "value": "contract"}, + 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 == [{"event": "message", "value": "contract"}] - await channel.send({"event": "message", "value": "contract"}) - await channel.unsubscribe() + assert received == ["fresh"] + await client.realtime.disconnect() + + asyncio.run(scenario()) + 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" - client.auth.sign_in(email="user@example.com", password="secret") - assert await factory_arguments["get_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 + await asyncio.gather(*tuple(client.realtime._auth_cleanup_tasks)) + assert first.calls[-1] == "disconnect" + await channel.subscribe() await client.realtime.disconnect() asyncio.run(scenario()) - assert factory_arguments["address"] == ( - "wss://api.test.volcano.dev/realtime/v1/websocket?apikey=anon%20key" + +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), ) - assert factory_arguments["token"] == "access-1" - assert official.calls == ["connect", "channel:broadcast:contract", "disconnect"] + 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_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 official.subscription.calls == [ - ("subscribe", None), - ("publish", {"event": "message", "value": "contract"}), - ("unsubscribe", None), - ] - assert received == [{"event": "message", "value": "contract"}] + 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_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() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + received: list[str] = [] + channel = client.realtime.channel("contract").on( + "message", lambda data: received.append(data["value"]) + ) + + async def exercise(value: str) -> None: + 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: + 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_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: + 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() + + asyncio.run(scenario()) def test_realtime_callbacks_run_outside_the_message_processor() -> None: @@ -257,6 +710,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() @@ -333,9 +826,99 @@ async def scenario() -> None: asyncio.run(scenario()) -def test_realtime_callback_failure_does_not_stop_later_callbacks() -> None: +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"] + + +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: transport = AuthTransport() official = FakeCentrifugeClient() + entered = asyncio.Event() + release = asyncio.Event() client = VolcanoClient( anon_key="anon-key", _transport=transport, @@ -345,43 +928,128 @@ def test_realtime_callback_failure_does_not_stop_later_callbacks() -> None: 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)) + await channel.subscribe() + assert official.subscription is not None - 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() + 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()) + + +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() + monkeypatch.setattr( + FakeSubscription, + "subscribe", + _failing_first_subscribe(entered, release), + ) + + 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] + + 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() + client = VolcanoClient( + anon_key="anon-key", + _transport=transport, + _realtime_client_factory=FakeCentrifugeFactory(official), + ) + client.auth.sign_in(email="user@example.com", password="secret") + asyncio.run(_exercise_callback_failure(client, official)) + + def test_realtime_callback_can_disconnect_its_own_client() -> None: transport = AuthTransport() official = FakeCentrifugeClient() @@ -461,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: @@ -511,6 +1214,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() diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py index 5e4f12ef..5a812300 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) @@ -27,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", + }, }, ) @@ -53,6 +58,74 @@ 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_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, + match="refresh_token requires access_token", + ): + 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( + 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) 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" }, ]