From 796c9ed6e30f6471cec6cf8c10b4fb670030c112 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 11 Aug 2026 11:21:04 +0530 Subject: [PATCH 01/12] Added Anonymous Session Support Changes --- README.md | 4 + examples/AnonymousSessions.md | 158 ++++ .../auth_server/__init__.py | 3 +- .../auth_server/anonymous_client.py | 606 +++++++++++++++ .../auth_server/server_client.py | 52 +- .../auth_types/__init__.py | 73 ++ src/auth0_server_python/error/__init__.py | 109 +++ .../tests/test_anonymous_client.py | 707 ++++++++++++++++++ .../tests/test_server_client.py | 469 +++++++++++- src/auth0_server_python/utils/helpers.py | 30 + 10 files changed, 2208 insertions(+), 3 deletions(-) create mode 100644 examples/AnonymousSessions.md create mode 100644 src/auth0_server_python/auth_server/anonymous_client.py create mode 100644 src/auth0_server_python/tests/test_anonymous_client.py diff --git a/README.md b/README.md index 7e6c165..e88fdc9 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,10 @@ Let a logged-in user manage their own enrolled authentication methods — enroll Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) so a stolen token alone cannot be replayed. DPoP is supported for Passkey sign-in (`signin_with_passkey`) and the authentication-methods/factors methods on `MyAccountClient`. For key generation and usage, see [examples/Passkeys.md](examples/Passkeys.md#3-dpop-bound-passkey-tokens-optional) and [examples/MyAccountAuthenticationMethods.md](examples/MyAccountAuthenticationMethods.md#dpop). +### 10. Anonymous Sessions + +Give a visitor an Auth0 `anon@` identity before they log in, so cart/preference metadata attached pre-login is available to Post-Login Actions once they do. Requires a separate `anonymous_store` instance — never the same instance as `state_store` — and a tenant-level paid add-on flag. For setup, the token renewal ladder, login injection, and the store-isolation requirement, see [examples/AnonymousSessions.md](examples/AnonymousSessions.md). + ## Feedback ### Contributing diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md new file mode 100644 index 0000000..3f700d2 --- /dev/null +++ b/examples/AnonymousSessions.md @@ -0,0 +1,158 @@ +# Anonymous Sessions + +Anonymous Sessions give a visitor an Auth0 identity **before they log in**. Each visitor gets a persistent `anon@` subject plus an access token, with up to 1 KB of key/value metadata (cart, preferences) attached at creation. At login, the session token rides into `/authorize` so Post-Login / Pre-User-Registration Actions can read the anonymous data via `event.anonymous_session` — nothing migrates onto the real user profile automatically; the Action author decides what to persist. + +> [!NOTE] +> Anonymous Sessions support for server SDKs is in Early Access, gated by a tenant-level, paid add-on feature flag (`anonymous_sessions_enabled`). `auth0-server-python` mounts no routes and sets no cookies — this guide covers the framework-agnostic core only. + +## Table of Contents + +- [Anonymous Sessions](#anonymous-sessions) + - [Table of Contents](#table-of-contents) + - [Setup](#setup) + - [The Anonymous Store — Read This Before Configuring Anything](#the-anonymous-store--read-this-before-configuring-anything) + - [Creating a Session](#creating-a-session) + - [Getting a Token (Renewal Ladder)](#getting-a-token-renewal-ladder) + - [Introspecting a Session](#introspecting-a-session) + - [Logging Out](#logging-out) + - [Login Injection](#login-injection) + - [Rate-Limiting `get_token()`](#rate-limiting-get_token) + - [Error Handling](#error-handling) + - [Known Limitations](#known-limitations) + - [Additional Resources](#additional-resources) + +## Setup + +Before using the anonymous sessions API, the `anonymous_sessions_enabled` flag must be turned on for your tenant (contact your Auth0 account team — there is no self-serve path yet), and the application/client must be enabled for the feature. + +Pass an `anonymous_store` to `ServerClient`, alongside your existing `state_store` and `transaction_store`: + +```python +server_client = ServerClient( + domain="your-tenant.auth0.com", + client_id="...", + client_secret="...", + secret="...", + state_store=my_state_store, + transaction_store=my_transaction_store, + anonymous_store=my_anonymous_store, # see below — read before wiring this up +) +``` + +## The Anonymous Store — Read This Before Configuring Anything + +> [!WARNING] +> **`anonymous_store` MUST be a distinct store *instance* from `state_store` — not merely a different identifier passed to the same instance.** +> +> On the default `auth0-fastapi` cookie-backed stores (`StatelessStateStore`, `CookieTransactionStore`), the `identifier` argument to `set`/`get`/`delete` is used **only as an encryption salt** — the physical cookie name comes from the store instance's own `cookie_name`, fixed at construction. Two different identifiers written through the *same* store instance land on the *same* cookie and collide: the second write overwrites the first, and the failed decrypt on the next read is silently swallowed. Concretely, if you point `anonymous_store` at the same instance as `state_store`: +> +> - **Anonymous session created, then user logs in:** the login overwrites the anonymous session's cookie. The anonymous context is gone — the `sub`/metadata correlation this feature exists to deliver silently never happens. +> - **User logged in, then an anonymous session is created on the same request cycle:** the anonymous write overwrites the authenticated session's cookie. The next `get_session()` call decrypts garbage, returns `None`, and **the user is silently logged out** — no exception, no log line. +> +> Give `anonymous_store` its own `cookie_name` (or key prefix, or table) — a different construction, not a different string passed to the same one. If you omit `anonymous_store` entirely, every `.anonymous.*` call raises `ConfigurationError` immediately, before any write — it never falls back to `state_store`. + +This is not a hypothetical: it is the same root cause already live in this SDK's own `MfaClient`, which writes a second identifier (`_a0_mfa_pending`) into the shared state store today. If you are implementing a custom store, treat `identifier` as a value your store must resolve to a genuinely distinct record — not merely a distinct encryption salt on a fixed location. + +## Creating a Session + +```python +session = await server_client.anonymous.create_session( + audience="https://api.example.com", + scope="read:cart write:cart", + metadata={"cart_id": "cart_456"}, + store_options=store_options, +) +``` + +`metadata` is **set once, at creation, and never updated** — there is no platform update endpoint for anonymous sessions. Top-level string values only, ≤1 KB total (UTF-8 JSON byte length); oversized or non-string values are rejected client-side before any network call. + +`AnonymousSession` never exposes the raw session token — only `sub`, `session_id`, `access_token`, `expires_at`, `session_expires_at`, `metadata`, and `is_new`. + +> [!IMPORTANT] +> **Always check `is_new`.** It is `True` both on the first call to `create_session()` and on a *silent* re-mint (see below) — the only signal your application receives when the anonymous `sub` has changed. Any code correlating data on `sub` (e.g. a cart keyed by anonymous user) must check this on every call, not just the first. + +## Getting a Token (Renewal Ladder) + +```python +token = await server_client.anonymous.get_token(store_options=store_options) +``` + +Renewal logic, in order: + +1. Cached access token still fresh → returned with no network call. +2. Expired → re-minted using the stored session token (not a refresh-token grant — anonymous sessions never issue refresh tokens). +3. Session token also expired or invalid → a **brand-new session is silently created, once**. Metadata from the old session is permanently lost, and `sub` changes. This never raises — an anonymous pre-login session carries no authorization, so re-minting crosses no trust boundary. +4. Any other error → raised as a typed exception. No swallow, no auto-retry beyond the one re-mint in step 3. + +## Introspecting a Session + +```python +status = await server_client.anonymous.introspect(store_options=store_options) +``` + +> [!CAUTION] +> **This return shape is provisional.** The platform has not finalized what fields `/anonymous/userinfo` returns. The SDK's `AnonymousSessionIntrospection` model declares only `sub`, `session_id`, `expires_at`, and `metadata`, and ignores anything else in the response — a follow-up SDK release adding fields here is expected, not a breaking change. `introspect()` is a pure read: it never triggers the renewal ladder and never writes to the store on the SDK side. Whether the platform's own endpoint re-mints server-side as a side effect of being called is unconfirmed — treat that as a caveat if you observe it, not an SDK guarantee either way. + +## Logging Out + +```python +await server_client.anonymous.logout(store_options=store_options) +``` + +> [!CAUTION] +> **`logout()` does not revoke.** There is no server-side anonymous session store to revoke against — this clears only the locally-held encrypted context. Any access token already issued for this anonymous session remains valid until its natural expiry. + +## Login Injection + +When an anonymous session is active, `start_interactive_login()` automatically includes the session token in the `/authorize` request — no code change needed at your call site. If no anonymous session exists, behavior is byte-identical to today. If the stored anonymous token is malformed or undecryptable, the link is silently dropped and the login proceeds normally — a broken anonymous session never blocks login. + +The session token is **only ever sourced from the SDK's own encrypted anonymous store** — there is no public API through which a caller can supply one directly, and any attempt to smuggle one in via `authorization_params` (constructor or per-call) is stripped before the request is built. This is deliberate: it closes a session-fixation vector where an attacker's anonymous session could otherwise be linked onto a victim's fresh login. + +The token travels as a query parameter to `/authorize`, which means it lands in browser history, `Referer` headers, and access logs. This is accepted because the token grants no authorization on its own and the request is a browser-to-Auth0 HTTPS redirect — but you should still set `Referrer-Policy: no-referrer` on your login pages, and never log the authorize URL. + +Pushed Authorization Requests (PAR) are not supported for anonymous sessions — injection is suppressed entirely on that code path. + +## Rate-Limiting `get_token()` + +`get_token()`'s retry-once bound caps amplification to two upstream Auth0 calls *per invocation* — it does not protect against an attacker calling your route repeatedly. `POST /anonymous/token` is an unauthenticated, token-issuing endpoint. **You must rate-limit any route in your application that calls `get_token()` on an anonymous session**, the same way you would rate-limit any other unauthenticated token-issuing path. The SDK has no request-level context to do this itself. + +## Error Handling + +All anonymous session errors subclass `AnonymousApiError`, carrying a `.code` you can branch on: + +```python +from auth0_server_python.error import ( + AnonymousFeatureNotEnabledError, # tenant flag is off + AnonymousClientNotEnabledError, # client not enabled for anonymous sessions + AnonymousClientNotSupportedError, # e.g. a DPoP-mandated client — see Known Limitations + AnonymousResourceServerError, # audience not a valid/enabled resource server + AnonymousScopeError, # scope not granted to anonymous callers + AnonymousSessionCreateError, # base class for create/re-mint failures + AnonymousTokenError, # get_token() failure with no active session + AnonymousSessionIntrospectError, + AnonymousLogoutError, +) + +try: + session = await server_client.anonymous.create_session(audience="...", scope="...") +except AnonymousFeatureNotEnabledError: + # tenant configuration problem — not a code bug + ... +``` + +`.cause` is scrubbed of `client_secret`, `session_token`, `access_token`, and related fields recursively before it is stored, so it is always safe to log. + +## Known Limitations + +- **Metadata is attacker-authored, pre-auth input.** By the time a Post-Login Action reads `event.anonymous_session.metadata`, it is untrusted data from an unauthenticated caller. The SDK validates size and rejects dangerous keys, but your Action author is responsible for validating content before trusting or persisting it. +- **Single audience per session.** `get_token()` takes no `audience` parameter — one anonymous session serves exactly one audience. To call two APIs anonymously, create two sessions (and accept that each has independent metadata and lifecycle). +- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API — this is a structural exclusion, not a runtime check. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions; you will see `AnonymousClientNotSupportedError`. +- **PAR, CIBA, Device Flow, RAR, and mTLS clients are not supported** for anonymous sessions. +- **Multiple Custom Domains (MCD):** the SDK gates on domain to prevent an anonymous session minted against one tenant being served on a resolver call for a different tenant — a mismatch silently mints a fresh session under the current tenant rather than serving cross-tenant state. +- **No server-side revocation.** See [Logging Out](#logging-out) above. + +## Additional Resources + +- [MFA.md](MFA.md) — anonymous sessions are unrelated to MFA and never interact with it. +- [ConfigureStore.md](ConfigureStore.md) — general store implementation guidance; anonymous sessions add the distinct-instance requirement above on top of everything there. +- [MultipleCustomDomains.md](MultipleCustomDomains.md) — background on the resolver-mode domain gating referenced above. diff --git a/src/auth0_server_python/auth_server/__init__.py b/src/auth0_server_python/auth_server/__init__.py index 611f6b7..9bf85e8 100644 --- a/src/auth0_server_python/auth_server/__init__.py +++ b/src/auth0_server_python/auth_server/__init__.py @@ -1,5 +1,6 @@ +from .anonymous_client import AnonymousClient from .mfa_client import MfaClient from .my_account_client import MyAccountClient from .server_client import ServerClient -__all__ = ["ServerClient", "MyAccountClient", "MfaClient"] +__all__ = ["ServerClient", "MyAccountClient", "MfaClient", "AnonymousClient"] diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py new file mode 100644 index 0000000..5b088d9 --- /dev/null +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -0,0 +1,606 @@ +""" +Anonymous Sessions client for auth0-server-python SDK. +Handles pre-login anon@ identity operations against the Auth0 anonymous session API. +""" + +import json +import time +from typing import Any, Optional + +import httpx +from pydantic import ValidationError + +from auth0_server_python.auth_schemes.bearer_auth import BearerAuth +from auth0_server_python.auth_types import ( + AnonymousSession, + AnonymousSessionContext, + AnonymousSessionIntrospection, + AnonymousTokenResponse, +) +from auth0_server_python.encryption.encrypt import decrypt, encrypt +from auth0_server_python.error import ( + AnonymousApiError, + AnonymousClientNotEnabledError, + AnonymousClientNotSupportedError, + AnonymousFeatureNotEnabledError, + AnonymousLogoutError, + AnonymousResourceServerError, + AnonymousScopeError, + AnonymousSessionCreateError, + AnonymousSessionIntrospectError, + AnonymousTokenError, + ConfigurationError, + DomainResolverError, + _AnonymousSessionExpired, +) +from auth0_server_python.utils.helpers import ( + build_domain_resolver_context, + validate_resolved_domain_value, +) + +# Salt only — isolation comes from the store instance, not this key. +ANON_IDENTIFIER = "_a0_anon" +ANON_TOKEN_SALT = "anon_session" + +_METADATA_MAX_BYTES = 1024 +_DANGEROUS_METADATA_KEYS = frozenset({"__proto__", "constructor", "prototype"}) + + +class AnonymousClient: + """ + Client for Auth0 anonymous session operations. + + Requires its own store instance, distinct from ServerClient's state_store — + a shared identifier isn't sufficient isolation on the default auth0-fastapi + store. Never accepts a dpop_key: DPoP-mandated clients are structurally + excluded from anonymous sessions. + """ + + def __init__( + self, + domain, + client_id: str, + client_secret: str, + secret: str, + anonymous_store=None, + default_audience: Optional[str] = None, + default_scope: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + ): + if callable(domain): + self._domain = None + self._domain_resolver = domain + else: + self._domain = domain + self._domain_resolver = None + self._client_id = client_id + self._client_secret = client_secret + self._secret = secret + self._anonymous_store = anonymous_store + self._default_audience = default_audience + self._default_scope = default_scope + self._headers = headers or {} + + def _get_http_client(self, **kwargs) -> httpx.AsyncClient: + """Return an httpx.AsyncClient with default headers injected.""" + headers = {**kwargs.pop("headers", {}), **self._headers} + return httpx.AsyncClient(headers=headers, **kwargs) + + def _require_store(self) -> None: + """Fail closed before any write when no anonymous store is configured.""" + if self._anonymous_store is None: + raise ConfigurationError( + "AnonymousClient requires its own anonymous_store, distinct from " + "ServerClient's state_store. Writing anonymous state into the same " + "store instance can silently overwrite the authenticated session." + ) + + async def _resolve_domain(self, store_options: Optional[dict[str, Any]] = None) -> str: + """Resolve domain from resolver function or return static domain.""" + if self._domain_resolver: + context = build_domain_resolver_context(store_options) + try: + resolved = await self._domain_resolver(context) + return validate_resolved_domain_value(resolved) + except DomainResolverError: + raise + except Exception as e: + raise DomainResolverError( + f"Domain resolver function raised an exception: {str(e)}", + original_error=e, + ) + return self._domain + + @staticmethod + def _normalize_url(value: Optional[str]) -> Optional[str]: + """Normalize a domain-like value for comparison (scheme + case + trailing slash).""" + if not value: + return value + value = value.lower() + if value.startswith("https://"): + pass + elif value.startswith("http://"): + value = value.replace("http://", "https://") + else: + value = f"https://{value}" + return value.rstrip("/") + + # ============================================================================ + # ERROR HANDLING + # ============================================================================ + + @staticmethod + def _parse_anonymous_error_body(response: httpx.Response) -> dict[str, Any]: + """Parse an error response body as JSON. Kept private to this module — + do not merge with MfaClient._parse_error_body.""" + try: + data = response.json() + except (json.JSONDecodeError, ValueError): + data = None + if not isinstance(data, dict): + return { + "error_description": f"Request failed with status {response.status_code}", + } + return data + + def _map_anonymous_error( + self, + status_code: int, + error_data: dict[str, Any], + operation: str, + ) -> Exception: + """ + Single dispatcher from a server error response to a typed exception. + + Returns the exception instance (does not raise it) so every call site + raises the same way: `raise self._map_anonymous_error(...)`. + """ + code = error_data.get("error", "") + description = error_data.get("error_description") or f"Anonymous {operation} failed" + + if code in ("session_expired", "invalid_session_token"): + return _AnonymousSessionExpired(description) + # Distinguishes DPoP-mandated clients from a plain client-not-enabled block. + if status_code == 400 and "Proof-of-Possession" in description: + return AnonymousClientNotSupportedError(description, error_data) + if code == "feature_not_enabled": + return AnonymousFeatureNotEnabledError(description, error_data) + if code == "unauthorized_client": + return AnonymousClientNotEnabledError(description, error_data) + if code in ("invalid_target", "invalid_request"): + return AnonymousResourceServerError(description, error_data) + if code == "invalid_scope": + return AnonymousScopeError(description, error_data) + + if operation == "create": + return AnonymousSessionCreateError(description, cause=error_data) + if operation == "token": + return AnonymousTokenError(description, error_data) + if operation == "logout": + return AnonymousLogoutError(description, error_data) + if operation == "introspect": + return AnonymousSessionIntrospectError(description, error_data) + return AnonymousApiError(code or "anonymous_error", description, error_data) + + # ============================================================================ + # METADATA VALIDATION + # ============================================================================ + + @staticmethod + def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: + """Client-side pre-flight so an oversized/invalid payload never reaches the network.""" + if metadata is None: + return + if not isinstance(metadata, dict): + raise AnonymousSessionCreateError("metadata must be a JSON object", code="invalid_metadata") + for key, value in metadata.items(): + if key in _DANGEROUS_METADATA_KEYS: + raise AnonymousSessionCreateError( + f"metadata key '{key}' is not allowed", code="invalid_metadata" + ) + if not isinstance(value, str): + raise AnonymousSessionCreateError( + f"metadata value for key '{key}' must be a string", code="invalid_metadata" + ) + size = len(json.dumps(metadata).encode("utf-8")) + if size > _METADATA_MAX_BYTES: + raise AnonymousSessionCreateError( + "metadata exceeds the 1KB size limit", code="metadata_too_large" + ) + + # ============================================================================ + # ENCRYPTION + # ============================================================================ + + def _encrypt_context(self, context: AnonymousSessionContext) -> str: + return encrypt(context.model_dump(), self._secret, ANON_TOKEN_SALT) + + def _decrypt_context(self, stored: Any) -> AnonymousSessionContext: + """ + Decrypt and validate a stored anonymous session record. + + Mirrors MfaClient.decrypt_mfa_token's broad except: crypto-library and + pydantic-validation failure modes are both "this record is unusable," + and both must convert to the same internal signal, never propagate an + untyped exception to a caller. + """ + try: + encrypted = stored.get("context") if isinstance(stored, dict) else None + if not encrypted: + raise ValueError("Malformed anonymous session record") + payload = decrypt(encrypted, self._secret, ANON_TOKEN_SALT) + return AnonymousSessionContext(**payload) + except Exception as e: + raise _AnonymousSessionExpired( + "Stored anonymous session token is invalid or corrupted." + ) from e + + # ============================================================================ + # LOGIN INJECTION SUPPORT + # ============================================================================ + + async def get_session_token_for_injection( + self, store_options: Optional[dict[str, Any]] = None + ) -> Optional[str]: + """ + Read the active anonymous session's raw token for injection into + start_interactive_login(), without triggering the renewal ladder. + + Never raises: no configured store, no active session, or an + undecryptable/corrupted record all return None — malformed linking + state must deny the link, not abort the login. + """ + if self._anonymous_store is None: + return None + try: + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + except Exception: + return None + if not stored: + return None + try: + context = self._decrypt_context(stored) + except _AnonymousSessionExpired: + return None + return context.session_token + + # ============================================================================ + # SESSION CREATION + # ============================================================================ + + async def create_session( + self, + *, + audience: Optional[str] = None, + scope: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + store_options: Optional[dict[str, Any]] = None, + ) -> AnonymousSession: + """ + Mint a fresh anon@ identity via POST /anonymous/token. + + Raises: + ConfigurationError: No anonymous_store configured. + AnonymousSessionCreateError: Local validation or server rejection. + """ + self._require_store() + self._validate_metadata(metadata) + audience = audience or self._default_audience + scope = scope or self._default_scope + domain = await self._resolve_domain(store_options) + return await self._create_session_at( + domain, audience=audience, scope=scope, metadata=metadata, store_options=store_options + ) + + async def _create_session_at( + self, + domain: str, + *, + audience: Optional[str], + scope: Optional[str], + metadata: Optional[dict[str, Any]], + store_options: Optional[dict[str, Any]], + ) -> AnonymousSession: + """Shared create-mode HTTP call, used by create_session() and every renewal-ladder fallback.""" + base_url = f"https://{domain}" + body: dict[str, Any] = {"client_id": self._client_id} + if self._client_secret: + body["client_secret"] = self._client_secret + if audience: + body["audience"] = audience + if scope: + body["scope"] = scope + if metadata: + body["metadata"] = metadata + + async with self._get_http_client() as client: + try: + response = await client.post(f"{base_url}/anonymous/token", json=body) + except httpx.HTTPError as e: + raise AnonymousSessionCreateError( + "Failed to reach the anonymous token endpoint" + ) from e + + if response.status_code != 200: + error_data = self._parse_anonymous_error_body(response) + mapped = self._map_anonymous_error(response.status_code, error_data, "create") + if isinstance(mapped, _AnonymousSessionExpired): + # Internal-only type must never escape. + raise AnonymousSessionCreateError(str(mapped)) + raise mapped + + try: + token_response = AnonymousTokenResponse.model_validate(response.json()) + except (json.JSONDecodeError, ValueError, ValidationError) as e: + raise AnonymousSessionCreateError( + "Failed to parse anonymous token response" + ) from e + + if not token_response.session_token or not token_response.sub or not token_response.session_id: + raise AnonymousSessionCreateError("Anonymous token response missing required fields") + + now = int(time.time()) + context = AnonymousSessionContext( + session_token=token_response.session_token, + sub=token_response.sub, + session_id=token_response.session_id, + access_token=token_response.access_token, + expires_at=now + token_response.expires_in, + session_expires_at=( + now + token_response.session_expires_in + if token_response.session_expires_in + else None + ), + metadata=metadata, + created_at=now, + domain=domain, + audience=audience, + scope=scope, + ) + await self._anonymous_store.set( + ANON_IDENTIFIER, + {"context": self._encrypt_context(context)}, + options=store_options, + ) + return AnonymousSession( + sub=context.sub, + session_id=context.session_id, + access_token=context.access_token, + expires_at=context.expires_at, + session_expires_at=context.session_expires_at, + metadata=context.metadata, + is_new=True, + ) + + # ============================================================================ + # TOKEN RENEWAL LADDER + # ============================================================================ + + async def get_token( + self, store_options: Optional[dict[str, Any]] = None + ) -> AnonymousSession: + """ + Return a valid anonymous access token, renewing or re-minting as needed. + + 1. Cached access token still fresh -> return it. + 2. Expired -> re-mint with the session token (never a refresh-token grant). + 3. Session token also expired/invalid, corrupted, or minted for a + different tenant (MCD) -> silently create a brand-new session, once. + 4. Any other error -> raise. No swallow, no auto-retry beyond step 3. + + Raises: + ConfigurationError: No anonymous_store configured. + AnonymousTokenError: No active session, or an unrecoverable failure. + """ + self._require_store() + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + if not stored: + raise AnonymousTokenError("No active anonymous session. Call create_session() first.") + + try: + context = self._decrypt_context(stored) + except _AnonymousSessionExpired: + # No audience/scope to recover — fall back to configured defaults. + domain = await self._resolve_domain(store_options) + return await self._create_session_at( + domain, + audience=self._default_audience, + scope=self._default_scope, + metadata=None, + store_options=store_options, + ) + + if self._domain_resolver: + current_domain = await self._resolve_domain(store_options) + if context.domain and self._normalize_url(context.domain) != self._normalize_url( + current_domain + ): + # Cross-tenant reuse must be structurally impossible — discard + # and mint fresh under the current tenant instead. + return await self._create_session_at( + current_domain, + audience=context.audience, + scope=context.scope, + metadata=None, + store_options=store_options, + ) + + now = int(time.time()) + if context.expires_at > now: + return AnonymousSession( + sub=context.sub, + session_id=context.session_id, + access_token=context.access_token, + expires_at=context.expires_at, + session_expires_at=context.session_expires_at, + metadata=context.metadata, + is_new=False, + ) + + return await self._remint(context, store_options) + + async def _remint( + self, context: AnonymousSessionContext, store_options: Optional[dict[str, Any]] + ) -> AnonymousSession: + """Re-mint an access token using the stored session token, with a retry-once fallback.""" + domain = context.domain or await self._resolve_domain(store_options) + base_url = f"https://{domain}" + body: dict[str, Any] = {"client_id": self._client_id, "session_token": context.session_token} + if self._client_secret: + body["client_secret"] = self._client_secret + + async with self._get_http_client() as client: + try: + response = await client.post(f"{base_url}/anonymous/token", json=body) + except httpx.HTTPError as e: + raise AnonymousTokenError("Failed to reach the anonymous token endpoint") from e + + if response.status_code != 200: + error_data = self._parse_anonymous_error_body(response) + mapped = self._map_anonymous_error(response.status_code, error_data, "token") + if isinstance(mapped, _AnonymousSessionExpired): + # Retry-once: exactly one follow-up create call, never a loop. + return await self._create_session_at( + domain, + audience=context.audience, + scope=context.scope, + metadata=None, + store_options=store_options, + ) + raise mapped + + try: + token_response = AnonymousTokenResponse.model_validate(response.json()) + except (json.JSONDecodeError, ValueError, ValidationError) as e: + raise AnonymousTokenError("Failed to parse anonymous token response") from e + + now = int(time.time()) + new_context = AnonymousSessionContext( + # Rewrite when a fresh session_token is present, else keep the old one. + session_token=token_response.session_token or context.session_token, + sub=token_response.sub or context.sub, + session_id=token_response.session_id or context.session_id, + access_token=token_response.access_token, + expires_at=now + token_response.expires_in, + session_expires_at=( + now + token_response.session_expires_in + if token_response.session_expires_in + else context.session_expires_at + ), + metadata=context.metadata, + created_at=context.created_at, + domain=domain, + audience=context.audience, + scope=context.scope, + ) + await self._anonymous_store.set( + ANON_IDENTIFIER, + {"context": self._encrypt_context(new_context)}, + options=store_options, + ) + return AnonymousSession( + sub=new_context.sub, + session_id=new_context.session_id, + access_token=new_context.access_token, + expires_at=new_context.expires_at, + session_expires_at=new_context.session_expires_at, + metadata=new_context.metadata, + is_new=False, + ) + + # ============================================================================ + # INTROSPECTION + # ============================================================================ + + async def introspect( + self, store_options: Optional[dict[str, Any]] = None + ) -> AnonymousSessionIntrospection: + """ + Read-only status check via GET /anonymous/userinfo. + + Never triggers the renewal ladder and never writes to the store — + an unreadable stored context is a hard failure here, not a silent re-mint. + + Note: the platform's required auth mechanism for this endpoint is + unspecified. Bearer access_token is the working assumption; confirm + with the feature team before release. + """ + self._require_store() + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + if not stored: + raise AnonymousSessionIntrospectError("No active anonymous session to introspect.") + + try: + context = self._decrypt_context(stored) + except _AnonymousSessionExpired as e: + raise AnonymousSessionIntrospectError( + "Stored anonymous session is invalid or corrupted." + ) from e + + domain = context.domain or await self._resolve_domain(store_options) + base_url = f"https://{domain}" + + async with self._get_http_client() as client: + try: + response = await client.get( + f"{base_url}/anonymous/userinfo", + auth=BearerAuth(context.access_token), + ) + except httpx.HTTPError as e: + raise AnonymousSessionIntrospectError( + "Failed to reach the anonymous userinfo endpoint" + ) from e + + if response.status_code != 200: + error_data = self._parse_anonymous_error_body(response) + mapped = self._map_anonymous_error(response.status_code, error_data, "introspect") + if isinstance(mapped, _AnonymousSessionExpired): + raise AnonymousSessionIntrospectError(str(mapped)) + raise mapped + + try: + return AnonymousSessionIntrospection.model_validate(response.json()) + except (json.JSONDecodeError, ValueError, ValidationError) as e: + raise AnonymousSessionIntrospectError( + "Failed to parse anonymous introspection response" + ) from e + + # ============================================================================ + # LOGOUT + # ============================================================================ + + async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: + """ + Clear the locally-held anonymous session. + + No server-side revocation exists — access tokens already issued remain + valid until natural expiry. The remote POST below is best-effort only; + the local store clear is what actually ends the session from this SDK's + perspective. + """ + self._require_store() + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + if not stored: + return + + try: + context = self._decrypt_context(stored) + except _AnonymousSessionExpired: + context = None + + if context is not None: + domain = context.domain or await self._resolve_domain(store_options) + base_url = f"https://{domain}" + body: dict[str, Any] = { + "client_id": self._client_id, + "session_token": context.session_token, + } + if self._client_secret: + body["client_secret"] = self._client_secret + try: + async with self._get_http_client() as client: + await client.post(f"{base_url}/anonymous/logout", json=body) + except httpx.HTTPError: + pass + + await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index c8eb6b3..47ecc16 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -20,6 +20,7 @@ from pydantic import ValidationError from auth0_server_python.auth_schemes.dpop_auth import make_dpop_proof_for_token_endpoint +from auth0_server_python.auth_server.anonymous_client import AnonymousClient from auth0_server_python.auth_server.mfa_client import MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient from auth0_server_python.auth_types import ( @@ -85,7 +86,8 @@ # redirect_uri is intentionally excluded — in MCD mode it is built # dynamically from the resolved domain at login time. INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", - "code_challenge", "code_challenge_method", "state", "nonce", "scope"] + "code_challenge", "code_challenge_method", "state", "nonce", "scope", + "session_token"] # issued_token_type URN for a Session Transfer Token (STT). SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfer_token" @@ -117,6 +119,7 @@ def __init__( secret: str = None, transaction_store=None, state_store=None, + anonymous_store=None, transaction_identifier: str = "_a0_tx", state_identifier: str = "_a0_session", authorization_params: Optional[dict[str, Any]] = None, @@ -134,6 +137,14 @@ def __init__( secret: Secret used for encryption transaction_store: Custom transaction store (defaults to MemoryTransactionStore) state_store: Custom state store (defaults to MemoryStateStore) + anonymous_store: Store for anonymous session state (server_client.anonymous.*). + Must be a distinct store *instance* from state_store — not merely a + different identifier. On the default auth0-fastapi cookie stores, a + store identifier is used only as an encryption salt, not a location + key, so writing anonymous state through state_store would silently + overwrite the authenticated session cookie. When omitted, the + `.anonymous` sub-client fails closed on first use rather than + sharing state_store implicitly. transaction_identifier: Identifier for transaction data state_identifier: Identifier for state data authorization_params: Default parameters for authorization requests @@ -180,6 +191,7 @@ def __init__( # Initialize stores self._transaction_store = transaction_store self._state_store = state_store + self._anonymous_store = anonymous_store self._transaction_identifier = transaction_identifier self._state_identifier = state_identifier @@ -214,6 +226,20 @@ def __init__( headers=self._telemetry_headers, ) + # Deliberately given its own store, never self._state_store. + self._anonymous_client = AnonymousClient( + domain=domain, + client_id=self._client_id, + client_secret=self._client_secret, + secret=self._secret, + anonymous_store=self._anonymous_store, + default_audience=self._default_authorization_params.get("audience"), + default_scope=self._default_authorization_params.get("scope") + if isinstance(self._default_authorization_params.get("scope"), str) + else None, + headers=self._telemetry_headers, + ) + def _get_http_client(self, **kwargs) -> httpx.AsyncClient: """Return an httpx.AsyncClient with telemetry headers injected.""" headers = {**kwargs.pop("headers", {}), **self._telemetry_headers} @@ -543,6 +569,20 @@ async def start_interactive_login( if options.invitation: auth_params["invitation"] = options.invitation + # session_token is sourced only from the SDK's own encrypted anonymous + # store, never from a caller. INTERNAL_AUTHORIZE_PARAMS alone isn't + # enough — auth_params is seeded unfiltered from the constructor + # defaults above, so a caller-supplied value would survive that filter. + # Suppressed entirely on the PAR branch below (unsupported there). + auth_params.pop("session_token", None) + anonymous_session_token = None + if not self._pushed_authorization_requests: + anonymous_session_token = await self._anonymous_client.get_session_token_for_injection( + store_options + ) + if anonymous_session_token: + auth_params["session_token"] = anonymous_session_token + # Build the transaction data to store with domain transaction_data = TransactionData( code_verifier=code_verifier, @@ -551,6 +591,7 @@ async def start_interactive_login( domain=origin_domain, redirect_uri=auth_params.get("redirect_uri"), organization=resolved_org, + session_token=anonymous_session_token, ) # Store the transaction data @@ -2862,6 +2903,15 @@ def mfa(self) -> MfaClient: """Access the MFA client for multi-factor authentication operations.""" return self._mfa_client + # ============================================================================ + # ANONYMOUS SESSIONS + # ============================================================================ + + @property + def anonymous(self) -> AnonymousClient: + """Access the anonymous sessions client for pre-login anon@ identity operations.""" + return self._anonymous_client + # ============================================================================ # PASSKEY AUTHENTICATION # ============================================================================ diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index e886938..f73c9ab 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -146,6 +146,7 @@ class TransactionData(BaseModel): redirect_uri: Optional[str] = None domain: Optional[str] = None organization: Optional[str] = None + session_token: Optional[str] = None class Config: extra = "allow" # Allow additional fields not defined in the model @@ -852,3 +853,75 @@ class PasskeyTokenResponse(BaseModel): scope: Optional[str] = None id_token: Optional[str] = None refresh_token: Optional[str] = None + + +# ============================================================================= +# Anonymous Session Types +# ============================================================================= + + +class AnonymousSession(BaseModel): + """ + Public result of create_session() / the renewal ladder. + + Never exposes the raw session token — that stays inside the encrypted + AnonymousSessionContext, server-side only. + """ + + sub: str + session_id: str + access_token: str + expires_at: int + session_expires_at: Optional[int] = None + metadata: Optional[dict[str, Any]] = None + is_new: bool + + +class AnonymousSessionIntrospection(BaseModel): + """ + Result of introspect(). Deliberately minimal and lenient — the platform's + /anonymous/userinfo response shape is unconfirmed; unrecognized fields + are ignored rather than rejected. + """ + + model_config = ConfigDict(extra="ignore") + sub: str + session_id: Optional[str] = None + expires_at: Optional[int] = None + metadata: Optional[dict[str, Any]] = None + + +class AnonymousTokenResponse(BaseModel): + """Raw response from POST /anonymous/token.""" + + access_token: str + token_type: str = "Bearer" + expires_in: int + session_token: Optional[str] = None + session_expires_in: Optional[int] = None + sub: Optional[str] = None + session_id: Optional[str] = None + + +class AnonymousSessionContext(BaseModel): + """ + Internal context stored inside the encrypted anonymous session record. + + No `extra` config — decrypt fails closed on a tampered or malformed + payload rather than silently yielding a partial object. + """ + + session_token: str + sub: str + session_id: str + access_token: str + expires_at: int + session_expires_at: Optional[int] = None + metadata: Optional[dict[str, Any]] = None + created_at: int + # Resolved domain at creation time. Gated on in resolver/MCD mode so a + # session minted against tenant A cannot be read back for tenant B. + # None when the client uses a static domain. + domain: Optional[str] = None + audience: Optional[str] = None + scope: Optional[str] = None diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index ee9279f..c6bae6e 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -362,3 +362,112 @@ class PasskeyErrorCode: CHALLENGE_FAILED = "passkey_challenge_error" TOKEN_EXCHANGE_FAILED = "passkey_token_error" INVALID_RESPONSE = "invalid_response" + + +# ============================================================================= +# Anonymous Session Error Classes +# ============================================================================= + +class AnonymousApiError(Auth0Error): + """ + Base class for anonymous session API errors. + + Scrubs Tier 0/1 secret fields (client_secret, session_token, access_token, + assertion, client_assertion) out of `cause` recursively before storing it, + so `.cause` is always safe to log or surface. + """ + + def __init__( + self, + code: str, + message: str, + cause: Optional[dict[str, Any]] = None + ): + super().__init__(message) + self.code = code + if cause is not None: + # Deferred import: utils.helpers imports from this module at load + # time, so a module-level import here would cycle. + from auth0_server_python.utils.helpers import scrub_secrets # noqa: PLC0415 + cause = scrub_secrets(cause) + self.cause = cause + + +class AnonymousSessionCreateError(AnonymousApiError): + """Error thrown when creating or re-minting an anonymous session fails.""" + + def __init__(self, message: str, code: str = "anonymous_session_create_error", cause: Optional[dict] = None): + super().__init__(code, message, cause) + + +class AnonymousLogoutError(AnonymousApiError): + """Error thrown when anonymous logout fails.""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__("anonymous_logout_error", message, cause) + + +class AnonymousTokenError(AnonymousApiError): + """Error thrown when get_token() fails for reasons other than session expiry.""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__("anonymous_token_error", message, cause) + + +class AnonymousSessionIntrospectError(AnonymousApiError): + """ + Error thrown when introspect() fails. + + Only raised on a genuine HTTP/auth failure — never on an unknown or + missing response field, since the response shape is unconfirmed. + """ + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__("anonymous_session_introspect_error", message, cause) + + +class AnonymousFeatureNotEnabledError(AnonymousSessionCreateError): + """Error thrown when the tenant has not enabled the anonymous sessions add-on.""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__(message, "anonymous_feature_not_enabled_error", cause) + + +class AnonymousClientNotEnabledError(AnonymousSessionCreateError): + """Error thrown when the client is not enabled for anonymous sessions.""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__(message, "anonymous_client_not_enabled_error", cause) + + +class AnonymousClientNotSupportedError(AnonymousSessionCreateError): + """Error thrown when the client type does not support anonymous sessions (e.g. DPoP-mandated).""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__(message, "anonymous_client_not_supported_error", cause) + + +class AnonymousResourceServerError(AnonymousSessionCreateError): + """Error thrown when the requested audience is not a valid resource server.""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__(message, "anonymous_resource_server_error", cause) + + +class AnonymousScopeError(AnonymousSessionCreateError): + """Error thrown when the requested scope is not granted to anonymous callers.""" + + def __init__(self, message: str, cause: Optional[dict] = None): + super().__init__(message, "anonymous_scope_error", cause) + + +class _AnonymousSessionExpired(Auth0Error): + """ + Internal-only signal that the stored session token is expired or invalid. + + Drives the silent re-mint in the renewal ladder. Never raised to SDK callers. + """ + + def __init__(self, message: str = "The anonymous session token is expired or invalid."): + super().__init__(message) + self.name = "_AnonymousSessionExpired" diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py new file mode 100644 index 0000000..6a0f157 --- /dev/null +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -0,0 +1,707 @@ +""" +Tests for AnonymousClient — anonymous session API operations. +""" + +import inspect +import time +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from auth0_server_python.auth_server.anonymous_client import ( + ANON_IDENTIFIER, + AnonymousClient, +) +from auth0_server_python.auth_types import AnonymousSessionContext +from auth0_server_python.encryption.encrypt import encrypt +from auth0_server_python.error import ( + AnonymousClientNotEnabledError, + AnonymousClientNotSupportedError, + AnonymousFeatureNotEnabledError, + AnonymousResourceServerError, + AnonymousScopeError, + AnonymousSessionCreateError, + AnonymousSessionIntrospectError, + AnonymousTokenError, + ConfigurationError, + DomainResolverError, +) + +# Shared fixtures +DOMAIN = "auth0.local" +CLIENT_ID = "" +CLIENT_SECRET = "" +SECRET = "test-secret-long-enough-for-encryption" + + +class OneSlotStore: + """ + Models StatelessStateStore: a store identifier is a salt, not a location. + One physical slot per instance — a mismatched identifier reads as absent, + not as a different record. AsyncMock cannot catch a collision because it + treats every identifier as a distinct key; this fake is required instead. + """ + + def __init__(self): + self.slot = None + + async def set(self, identifier, state, options=None): + self.slot = (identifier, state) + + async def get(self, identifier, options=None): + if not self.slot or self.slot[0] != identifier: + return None + return self.slot[1] + + async def delete(self, identifier, options=None): + self.slot = None + + +def _make_client(anonymous_store=None, **kwargs) -> AnonymousClient: + return AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + secret=SECRET, + anonymous_store=anonymous_store, + **kwargs, + ) + + +def _fake_response(status_code=200, body=None): + response = MagicMock() + response.status_code = status_code + response.json = MagicMock(return_value=body or {}) + return response + + +class _FakeAsyncClient: + """Patches httpx.AsyncClient; call sequence maps 1:1 to responses.""" + + def __init__(self, responses): + self._responses = list(responses) + self.calls = [] + + def __call__(self, *args, **kwargs): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + return False + + async def post(self, url, **kwargs): + self.calls.append(("POST", url, kwargs)) + return self._responses.pop(0) + + async def get(self, url, **kwargs): + self.calls.append(("GET", url, kwargs)) + return self._responses.pop(0) + + +def _token_response( + access_token="AT1", # noqa: S107 + expires_in=3600, + session_token="ST1", # noqa: S107 + session_expires_in=2592000, + sub="anon@abc", + session_id="sid1", +): + return { + "access_token": access_token, + "token_type": "Bearer", + "expires_in": expires_in, + "session_token": session_token, + "session_expires_in": session_expires_in, + "sub": sub, + "session_id": session_id, + } + + +def _stored_context(store: OneSlotStore, **overrides): + defaults = { + "session_token": "ST1", + "sub": "anon@abc", + "session_id": "sid1", + "access_token": "AT1", + "expires_at": int(time.time()) + 3600, + "created_at": int(time.time()), + } + defaults.update(overrides) + context = AnonymousSessionContext(**defaults) + encrypted = encrypt(context.model_dump(), SECRET, "anon_session") + store.slot = (ANON_IDENTIFIER, {"context": encrypted}) + return context + + +# ── Constructor ────────────────────────────────────────────────────────────── + +class TestAnonymousClientConstructor: + def test_constructor_sets_properties(self): + client = _make_client() + assert client._domain == DOMAIN + assert client._domain_resolver is None + assert client._client_id == CLIENT_ID + assert client._anonymous_store is None + + def test_constructor_accepts_callable_domain(self): + resolver = AsyncMock(return_value="tenant.auth0.local") + client = AnonymousClient( + domain=resolver, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, secret=SECRET + ) + assert client._domain is None + assert client._domain_resolver is resolver + + def test_no_dpop_key_parameter_exists(self): + """Structural guard (D1/§6): AnonymousClient has no dpop_key parameter anywhere.""" + for name, method in inspect.getmembers(AnonymousClient, predicate=inspect.isfunction): + sig = inspect.signature(method) + assert "dpop_key" not in sig.parameters, f"{name} must never accept dpop_key" + + +# ── Fail-closed store isolation (D3a / B7) ─────────────────────────────────── + +class TestStoreIsolation: + @pytest.mark.asyncio + async def test_create_session_without_store_raises_configuration_error(self): + client = _make_client(anonymous_store=None) + with pytest.raises(ConfigurationError): + await client.create_session(audience="aud", scope="s") + + @pytest.mark.asyncio + async def test_get_token_without_store_raises_configuration_error(self): + client = _make_client(anonymous_store=None) + with pytest.raises(ConfigurationError): + await client.get_token() + + @pytest.mark.asyncio + async def test_introspect_without_store_raises_configuration_error(self): + client = _make_client(anonymous_store=None) + with pytest.raises(ConfigurationError): + await client.introspect() + + @pytest.mark.asyncio + async def test_logout_without_store_raises_configuration_error(self): + client = _make_client(anonymous_store=None) + with pytest.raises(ConfigurationError): + await client.logout() + + @pytest.mark.asyncio + async def test_no_write_attempted_when_store_missing(self): + """Fails closed BEFORE any store write — never falls back to another store.""" + client = _make_client(anonymous_store=None) + with patch("httpx.AsyncClient") as mock_http: + with pytest.raises(ConfigurationError): + await client.create_session(audience="aud", scope="s") + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_get_session_token_for_injection_returns_none_without_store(self): + client = _make_client(anonymous_store=None) + assert await client.get_session_token_for_injection() is None + + +# ── create_session ──────────────────────────────────────────────────────────── + +class TestCreateSession: + @pytest.mark.asyncio + async def test_create_session_success(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session( + audience="https://api.example.com", scope="read:cart", metadata={"cart_id": "c1"} + ) + assert session.sub == "anon@abc" + assert session.session_id == "sid1" + assert session.is_new is True + assert session.metadata == {"cart_id": "c1"} + + @pytest.mark.asyncio + async def test_create_session_sends_client_secret_in_json_body_not_auth_tuple(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + await client.create_session(audience="aud", scope="s") + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["client_secret"] == CLIENT_SECRET + assert "auth" not in kwargs + + @pytest.mark.asyncio + async def test_create_session_never_attaches_dpop_header(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + await client.create_session(audience="aud", scope="s") + _, _, kwargs = fake_http.calls[0] + assert "DPoP" not in kwargs.get("headers", {}) + + @pytest.mark.asyncio + async def test_create_session_persists_at_distinct_location_from_state_store(self): + """D3a: the anonymous store instance is separate from any authenticated session store.""" + anon_store = OneSlotStore() + state_store = OneSlotStore() + state_store.slot = ("_a0_session", {"user": "authenticated"}) + client = _make_client(anonymous_store=anon_store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + await client.create_session(audience="aud", scope="s") + assert anon_store.slot[0] == ANON_IDENTIFIER + # The authenticated session store is a different instance entirely — + # never touched by anonymous writes. + assert state_store.slot == ("_a0_session", {"user": "authenticated"}) + + @pytest.mark.asyncio + async def test_metadata_over_1kb_rejected_client_side_no_network_call(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + oversized = {"blob": "x" * 2000} + with patch("httpx.AsyncClient") as mock_http: + with pytest.raises(AnonymousSessionCreateError, match="1KB"): + await client.create_session(audience="aud", scope="s", metadata=oversized) + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_dangerous_metadata_key_rejected(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with pytest.raises(AnonymousSessionCreateError, match="not allowed"): + await client.create_session(audience="aud", scope="s", metadata={"__proto__": "x"}) + + @pytest.mark.asyncio + async def test_non_string_metadata_value_rejected(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with pytest.raises(AnonymousSessionCreateError, match="must be a string"): + await client.create_session(audience="aud", scope="s", metadata={"count": 5}) + + @pytest.mark.asyncio + async def test_feature_not_enabled_maps_to_typed_error(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(403, {"error": "feature_not_enabled", "error_description": "disabled"}) + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousFeatureNotEnabledError): + await client.create_session(audience="aud", scope="s") + + @pytest.mark.asyncio + async def test_unauthorized_client_maps_to_typed_error(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(403, {"error": "unauthorized_client", "error_description": "not enabled"}) + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousClientNotEnabledError): + await client.create_session(audience="aud", scope="s") + + @pytest.mark.asyncio + async def test_dpop_required_client_maps_to_not_supported_with_literal_message(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + message = "Client configuration requires the use of Proof-of-Possession mechanism" + fake_http = _FakeAsyncClient([ + _fake_response(400, {"error": "unauthorized_client", "error_description": message}) + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousClientNotSupportedError) as exc: + await client.create_session(audience="aud", scope="s") + assert message in str(exc.value) + + @pytest.mark.asyncio + async def test_invalid_target_maps_to_resource_server_error(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(400, {"error": "invalid_target", "error_description": "bad audience"}) + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousResourceServerError): + await client.create_session(audience="aud", scope="s") + + @pytest.mark.asyncio + async def test_invalid_scope_maps_to_scope_error(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(400, {"error": "invalid_scope", "error_description": "bad scope"}) + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousScopeError): + await client.create_session(audience="aud", scope="s") + + @pytest.mark.asyncio + async def test_secrets_never_leak_into_cause_even_nested(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(400, { + "error": "invalid_request", + "error_description": "bad", + "session_token": "LEAKED_TOKEN", + "details": {"client_secret": "LEAKED_SECRET"}, + }) + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousResourceServerError) as exc: + await client.create_session(audience="aud", scope="s") + cause_str = str(exc.value.cause) + assert "LEAKED_TOKEN" not in cause_str + assert "LEAKED_SECRET" not in cause_str + assert "[REDACTED]" in cause_str + + @pytest.mark.asyncio + async def test_network_failure_raises_create_error(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + + class _RaisingClient: + def __call__(self, *a, **k): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + raise httpx.ConnectError("boom") + + with patch("httpx.AsyncClient", _RaisingClient()): + with pytest.raises(AnonymousSessionCreateError): + await client.create_session(audience="aud", scope="s") + + +# ── get_token (renewal ladder) ──────────────────────────────────────────────── + +class TestGetToken: + @pytest.mark.asyncio + async def test_fresh_cached_token_returned_with_no_http_call(self): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) + 3600) + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + session = await client.get_token() + mock_http.assert_not_called() + assert session.is_new is False + assert session.access_token == "AT1" + + @pytest.mark.asyncio + async def test_no_active_session_raises_token_error(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with pytest.raises(AnonymousTokenError): + await client.get_token() + + @pytest.mark.asyncio + async def test_expired_access_token_remints_via_session_token_grant(self): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(200, {"access_token": "AT2", "token_type": "Bearer", "expires_in": 3600}) + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.access_token == "AT2" + assert session.is_new is False + assert session.sub == "anon@abc" # unchanged on ordinary re-mint + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["session_token"] == "ST1" + assert "refresh_token" not in kwargs["json"] + + @pytest.mark.asyncio + async def test_expired_session_token_triggers_silent_new_session(self): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(400, {"error": "session_expired", "error_description": "expired"}), + _fake_response(200, _token_response(sub="anon@new", session_id="sid2")), + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.is_new is True + assert session.sub == "anon@new" + + @pytest.mark.asyncio + async def test_silent_remint_drops_metadata(self): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10, metadata={"cart_id": "c1"}) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(400, {"error": "invalid_session_token", "error_description": "bad"}), + _fake_response(200, _token_response()), + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.metadata is None + + @pytest.mark.asyncio + async def test_two_consecutive_session_expired_raises_not_loops(self): + """Retry-once bound: exactly 2 upstream POSTs, then raise.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(400, {"error": "session_expired", "error_description": "expired"}), + _fake_response(400, {"error": "session_expired", "error_description": "expired again"}), + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousSessionCreateError): + await client.get_token() + assert len(fake_http.calls) == 2 + + @pytest.mark.asyncio + async def test_other_error_code_raises_typed_error_no_retry(self): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(403, {"error": "feature_not_enabled", "error_description": "off"}), + ]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousFeatureNotEnabledError): + await client.get_token() + assert len(fake_http.calls) == 1 + + @pytest.mark.asyncio + async def test_corrupted_stored_token_triggers_silent_new_session(self): + store = OneSlotStore() + store.slot = (ANON_IDENTIFIER, {"context": "not-a-valid-jwe"}) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response(sub="anon@fresh"))]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.is_new is True + assert session.sub == "anon@fresh" + + @pytest.mark.asyncio + async def test_network_error_during_renewal_not_misclassified_as_expiry(self): + """A broad exception must never be silently treated as session_expired.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + + class _RaisingClient: + def __call__(self, *a, **k): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + raise httpx.ConnectError("network down") + + with patch("httpx.AsyncClient", _RaisingClient()): + with pytest.raises(AnonymousTokenError): + await client.get_token() + + @pytest.mark.asyncio + async def test_get_token_never_writes_to_authenticated_state_store(self): + anon_store = OneSlotStore() + _stored_context(anon_store, expires_at=int(time.time()) + 3600) + auth_state_store = AsyncMock() + client = _make_client(anonymous_store=anon_store) + await client.get_token() + auth_state_store.set.assert_not_called() + auth_state_store.get.assert_not_called() + auth_state_store.delete.assert_not_called() + + +# ── MCD / cross-tenant isolation (B6) ──────────────────────────────────────── + +class TestMcdIsolation: + @pytest.mark.asyncio + async def test_domain_mismatch_in_resolver_mode_mints_fresh_under_current_tenant(self): + store = OneSlotStore() + _stored_context( + store, expires_at=int(time.time()) + 3600, domain="tenant-a.auth0.local" + ) + resolver = AsyncMock(return_value="tenant-b.auth0.local") + client = _make_client(anonymous_store=store) + client._domain_resolver = resolver + client._domain = None + fake_http = _FakeAsyncClient([_fake_response(200, _token_response(sub="anon@fresh-b"))]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.sub == "anon@fresh-b" + assert session.is_new is True + + @pytest.mark.asyncio + async def test_domain_resolver_failure_propagates(self): + resolver = AsyncMock(return_value=None) + client = AnonymousClient( + domain=resolver, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, secret=SECRET, + anonymous_store=OneSlotStore(), + ) + with pytest.raises(DomainResolverError): + await client.create_session(audience="aud", scope="s") + + +# ── introspect ──────────────────────────────────────────────────────────────── + +class TestIntrospect: + @pytest.mark.asyncio + async def test_introspect_issues_get_with_no_body(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {"sub": "anon@abc"})]) + with patch("httpx.AsyncClient", fake_http): + await client.introspect() + method, _, kwargs = fake_http.calls[0] + assert method == "GET" + assert "json" not in kwargs + + @pytest.mark.asyncio + async def test_introspect_lenient_decode_ignores_unknown_fields(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(200, {"sub": "anon@abc", "totally_unexpected_field": "value"}) + ]) + with patch("httpx.AsyncClient", fake_http): + result = await client.introspect() + assert result.sub == "anon@abc" + + @pytest.mark.asyncio + async def test_introspect_missing_optional_field_does_not_raise(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {"sub": "anon@abc"})]) + with patch("httpx.AsyncClient", fake_http): + result = await client.introspect() + assert result.session_id is None + assert result.metadata is None + + @pytest.mark.asyncio + async def test_introspect_never_writes_to_store(self): + store = OneSlotStore() + _stored_context(store) + original_slot = store.slot + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {"sub": "anon@abc"})]) + with patch("httpx.AsyncClient", fake_http): + await client.introspect() + assert store.slot == original_slot + + @pytest.mark.asyncio + async def test_introspect_no_active_session_raises(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with pytest.raises(AnonymousSessionIntrospectError): + await client.introspect() + + +# ── logout ──────────────────────────────────────────────────────────────────── + +class TestLogout: + @pytest.mark.asyncio + async def test_logout_clears_anonymous_store(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {})]) + with patch("httpx.AsyncClient", fake_http): + await client.logout() + assert store.slot is None + + @pytest.mark.asyncio + async def test_logout_does_not_touch_unrelated_authenticated_store(self): + anon_store = OneSlotStore() + _stored_context(anon_store) + auth_store = AsyncMock() + client = _make_client(anonymous_store=anon_store) + fake_http = _FakeAsyncClient([_fake_response(200, {})]) + with patch("httpx.AsyncClient", fake_http): + await client.logout() + auth_store.delete.assert_not_called() + + @pytest.mark.asyncio + async def test_get_token_after_logout_behaves_as_no_session(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {})]) + with patch("httpx.AsyncClient", fake_http): + await client.logout() + with pytest.raises(AnonymousTokenError): + await client.get_token() + + @pytest.mark.asyncio + async def test_logout_with_no_session_is_a_noop(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.logout() + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_logout_remote_call_failure_does_not_block_local_clear(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + + class _RaisingClient: + def __call__(self, *a, **k): + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + raise httpx.ConnectError("boom") + + with patch("httpx.AsyncClient", _RaisingClient()): + await client.logout() + assert store.slot is None + + +# ── get_session_token_for_injection (login-injection support) ─────────────── + +class TestGetSessionTokenForInjection: + @pytest.mark.asyncio + async def test_returns_token_when_active_session_exists(self): + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN") + client = _make_client(anonymous_store=store) + token = await client.get_session_token_for_injection() + assert token == "REAL_TOKEN" + + @pytest.mark.asyncio + async def test_returns_none_when_no_session(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + assert await client.get_session_token_for_injection() is None + + @pytest.mark.asyncio + async def test_returns_none_never_raises_on_corrupted_token(self): + """Malformed stored token must deny the link, never abort the caller (D1 §5 step 5).""" + store = OneSlotStore() + store.slot = (ANON_IDENTIFIER, {"context": "garbage"}) + client = _make_client(anonymous_store=store) + assert await client.get_session_token_for_injection() is None + + @pytest.mark.asyncio + async def test_returns_none_on_store_exception_never_raises(self): + store = AsyncMock() + store.get = AsyncMock(side_effect=RuntimeError("store unavailable")) + client = _make_client(anonymous_store=store) + assert await client.get_session_token_for_injection() is None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index c1c012a..2c8bc78 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -10,10 +10,12 @@ from jwcrypto import jwk from auth0_server_python.auth_schemes.dpop_auth import DPoPAuth +from auth0_server_python.auth_server.anonymous_client import ANON_IDENTIFIER, AnonymousClient from auth0_server_python.auth_server.mfa_client import MfaClient from auth0_server_python.auth_server.my_account_client import MyAccountClient -from auth0_server_python.auth_server.server_client import ServerClient +from auth0_server_python.auth_server.server_client import INTERNAL_AUTHORIZE_PARAMS, ServerClient from auth0_server_python.auth_types import ( + AnonymousSessionContext, CompleteConnectAccountRequest, ConnectAccountOptions, ConnectAccountRequest, @@ -39,6 +41,7 @@ TransactionData, UserClaims, ) +from auth0_server_python.encryption.encrypt import encrypt from auth0_server_python.error import ( AccessTokenError, AccessTokenErrorCode, @@ -8947,3 +8950,467 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker mock_state_store.set.assert_awaited_once() stored_state = mock_state_store.set.call_args.args[1] assert stored_state.internal.session_expires_at is None + + +# ============================================================================= +# ANONYMOUS SESSIONS — WIRING AND LOGIN-INJECTION TESTS +# ============================================================================= + + +class _OneSlotStore: + """ + Models StatelessStateStore: a store identifier is used only as an + encryption salt, not a location key — one physical slot per instance. + AsyncMock cannot exercise this collision because it treats every + identifier as a distinct key (see reviews/auth0-server-python/ + store-identifier-location-contract-collision.md). + """ + + def __init__(self): + self.slot = None + + async def set(self, identifier, state, options=None): + self.slot = (identifier, state) + + async def get(self, identifier, options=None): + if not self.slot or self.slot[0] != identifier: + return None + return self.slot[1] + + async def delete(self, identifier, options=None): + self.slot = None + + +def _make_anon_context(secret, **overrides): + defaults = { + "session_token": "ANON_TOKEN_1", + "sub": "anon@abc", + "session_id": "sid1", + "access_token": "anon_at1", + "expires_at": int(time.time()) + 3600, + "created_at": int(time.time()), + } + defaults.update(overrides) + context = AnonymousSessionContext(**defaults) + return encrypt(context.model_dump(), secret, "anon_session") + + +@pytest.mark.asyncio +async def test_server_client_anonymous_property(): + """ServerClient exposes an 'anonymous' property returning an AnonymousClient instance.""" + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + secret="a-test-secret-with-enough-length", + transaction_store=AsyncMock(), + state_store=AsyncMock(), + ) + assert isinstance(client.anonymous, AnonymousClient) + + +@pytest.mark.asyncio +async def test_anonymous_client_receives_own_store_not_state_store(): + """D3a: the anonymous client must never share the authenticated state store instance.""" + state_store = AsyncMock() + anon_store = _OneSlotStore() + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + secret="a-test-secret-with-enough-length", + transaction_store=AsyncMock(), + state_store=state_store, + anonymous_store=anon_store, + ) + assert client.anonymous._anonymous_store is anon_store + assert client.anonymous._anonymous_store is not state_store + + +@pytest.mark.asyncio +async def test_start_interactive_login_no_anonymous_session_is_byte_identical(mocker): + """No anonymous store configured -> injection is a complete no-op, existing behaviour unchanged.""" + mock_transaction_store = AsyncMock() + mock_state_store = AsyncMock() + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=mock_state_store, + transaction_store=mock_transaction_store, + secret="some-secret", + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + captured = {} + + def fake_create_url(endpoint, **kwargs): + captured.update(kwargs) + return ("https://auth0.local/authorize?client_id=", "some_state") + + mocker.patch.object(client._oauth, "create_authorization_url", side_effect=fake_create_url) + await client.start_interactive_login() + assert "session_token" not in captured + + +@pytest.mark.asyncio +async def test_start_interactive_login_injects_active_anonymous_session(mocker): + secret = "a-test-secret-with-enough-length" + anon_store = _OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + captured = {} + + def fake_create_url(endpoint, **kwargs): + captured.update(kwargs) + return ("https://auth0.local/authorize?client_id=", "some_state") + + mocker.patch.object(client._oauth, "create_authorization_url", side_effect=fake_create_url) + await client.start_interactive_login() + assert captured.get("session_token") == "ANON_TOKEN_1" + + +@pytest.mark.asyncio +async def test_start_interactive_login_stamps_session_token_into_transaction_data(mocker): + secret = "a-test-secret-with-enough-length" + anon_store = _OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + mock_transaction_store = AsyncMock() + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=mock_transaction_store, + anonymous_store=anon_store, + secret=secret, + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + mocker.patch.object( + client._oauth, + "create_authorization_url", + return_value=("https://auth0.local/authorize?client_id=", "some_state"), + ) + await client.start_interactive_login() + stored_tx = mock_transaction_store.set.call_args.args[1] + assert stored_tx.session_token == "ANON_TOKEN_1" + + +@pytest.mark.asyncio +async def test_start_interactive_login_absent_session_no_param(mocker): + """An empty anonymous store behaves exactly like no anonymous_store configured.""" + anon_store = _OneSlotStore() # no session ever created + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + captured = {} + + def fake_create_url(endpoint, **kwargs): + captured.update(kwargs) + return ("https://auth0.local/authorize?client_id=", "some_state") + + mocker.patch.object(client._oauth, "create_authorization_url", side_effect=fake_create_url) + await client.start_interactive_login() + assert "session_token" not in captured + + +@pytest.mark.asyncio +async def test_start_interactive_login_malformed_anonymous_token_denies_link_allows_login(mocker): + """Undecryptable stored token: deny the link, never abort the login (D1 §5 step 5).""" + anon_store = _OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": "not-a-valid-jwe"}) + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + mocker.patch.object( + client._oauth, + "create_authorization_url", + return_value=("https://auth0.local/authorize?client_id=", "some_state"), + ) + url = await client.start_interactive_login() + assert url == "https://auth0.local/authorize?client_id=" + + +@pytest.mark.asyncio +async def test_start_interactive_login_suppresses_injection_on_par_branch(mocker): + """PAR is not supported for anonymous sessions — the whole auth_params dict is POSTed there.""" + secret = "a-test-secret-with-enough-length" + anon_store = _OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + authorization_params={"redirect_uri": "/test_redirect_uri", "response_type": "code"}, + pushed_authorization_requests=True, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={ + "authorization_endpoint": "https://auth0.local/authorize", + "pushed_authorization_request_endpoint": "https://auth0.local/oauth/par", + }, + ) + captured = {} + + class _FakePost: + status_code = 201 + + def json(self): + return {"request_uri": "urn:ietf:params:oauth:request_uri:xyz"} + + class _FakeHttpClient: + def __init__(self, *a, **k): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, url, **kwargs): + captured.update(kwargs.get("data", {})) + return _FakePost() + + mocker.patch("httpx.AsyncClient", _FakeHttpClient) + await client.start_interactive_login() + assert "session_token" not in captured + + +@pytest.mark.asyncio +async def test_start_interactive_login_constructor_fixation_blocked_no_active_session(): + """ + D1 — the exact vector: a caller supplies session_token via constructor + authorization_params, with NO active anonymous session. INTERNAL_AUTHORIZE_PARAMS + alone cannot block this (it only filters per-call options.authorization_params); + the unconditional pop() at the injection site must. + """ + assert "session_token" in INTERNAL_AUTHORIZE_PARAMS # belt-and-braces still present + + anon_store = _OneSlotStore() # no session -> the vulnerable case + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + authorization_params={ + "redirect_uri": "/test_redirect_uri", + "session_token": "ATTACKER_SUPPLIED", + }, + ) + with patch.object( + client, + "_get_oidc_metadata_cached", + AsyncMock(return_value={"authorization_endpoint": "https://auth0.local/authorize"}), + ): + captured = {} + + def fake_create_url(endpoint, **kwargs): + captured.update(kwargs) + return ("https://auth0.local/authorize?client_id=", "some_state") + + with patch.object(client._oauth, "create_authorization_url", side_effect=fake_create_url): + await client.start_interactive_login() + assert captured.get("session_token") is None + + +@pytest.mark.asyncio +async def test_start_interactive_login_per_call_fixation_also_blocked(mocker): + """The same vector via options.authorization_params (per-call) is caught by the existing filter.""" + anon_store = _OneSlotStore() + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + captured = {} + + def fake_create_url(endpoint, **kwargs): + captured.update(kwargs) + return ("https://auth0.local/authorize?client_id=", "some_state") + + mocker.patch.object(client._oauth, "create_authorization_url", side_effect=fake_create_url) + await client.start_interactive_login( + StartInteractiveLoginOptions(authorization_params={"session_token": "ATTACKER_SUPPLIED"}) + ) + assert captured.get("session_token") is None + + +@pytest.mark.asyncio +async def test_start_interactive_login_does_not_clobber_organization_or_invitation(mocker): + secret = "a-test-secret-with-enough-length" + anon_store = _OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + client = ServerClient( + domain="auth0.local", + client_id="", + client_secret="", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + captured = {} + + def fake_create_url(endpoint, **kwargs): + captured.update(kwargs) + return ("https://auth0.local/authorize?client_id=", "some_state") + + mocker.patch.object(client._oauth, "create_authorization_url", side_effect=fake_create_url) + await client.start_interactive_login( + StartInteractiveLoginOptions(organization="org_abc123", invitation="inv_xyz") + ) + assert captured.get("organization") == "org_abc123" + assert captured.get("invitation") == "inv_xyz" + assert captured.get("session_token") == "ANON_TOKEN_1" + + +# ── Store-collision regression (D3a / B7 / tracker §7.6) ──────────────────── + + +@pytest.mark.asyncio +async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_store(): + """ + D3a proof: when the anonymous client is configured with its OWN store + instance (as constructed), the authenticated session on a separate store + instance is provably untouched — the separate-instance contract holds. + """ + shared_store = _OneSlotStore() + shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) + + anon_store = _OneSlotStore() + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + secret="a-test-secret-with-enough-length", + transaction_store=AsyncMock(), + state_store=shared_store, + anonymous_store=anon_store, + ) + await client.anonymous._anonymous_store.set( + ANON_IDENTIFIER, {"context": _make_anon_context("a-test-secret-with-enough-length")} + ) + + # The authenticated session, on its own store instance, is untouched. + assert shared_store.slot == ("_a0_session", {"user": {"sub": "real_user"}}) + session = await client.get_session() + assert session is not None + assert session.get("user", {}).get("sub") == "real_user" + + +@pytest.mark.asyncio +async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_store(): + """ + If an integrator forgets anonymous_store, the client must raise before any + write — never silently write anonymous state into ServerClient's state_store + (which is exactly the collision D3a prevents). + """ + shared_store = _OneSlotStore() + shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + secret="a-test-secret-with-enough-length", + transaction_store=AsyncMock(), + state_store=shared_store, + # anonymous_store intentionally omitted + ) + with pytest.raises(ConfigurationError): + await client.anonymous.create_session(audience="aud", scope="s") + # The authenticated session store is completely untouched by the failed attempt. + assert shared_store.slot == ("_a0_session", {"user": {"sub": "real_user"}}) + + +@pytest.mark.asyncio +async def test_get_session_and_get_user_unaffected_by_active_anonymous_session(): + """Anonymous state never touches _a0_session — get_session()/get_user() see no new keys.""" + secret = "a-test-secret-with-enough-length" + anon_store = _OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + mock_state_store = AsyncMock() + mock_state_store.get = AsyncMock(return_value=None) + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + secret=secret, + transaction_store=AsyncMock(), + state_store=mock_state_store, + anonymous_store=anon_store, + ) + assert await client.get_session() is None + assert await client.get_user() is None diff --git a/src/auth0_server_python/utils/helpers.py b/src/auth0_server_python/utils/helpers.py index e7d51cc..192b24b 100644 --- a/src/auth0_server_python/utils/helpers.py +++ b/src/auth0_server_python/utils/helpers.py @@ -399,3 +399,33 @@ def validate_org_claims(claims: dict, expected_org: str) -> None: raise OrganizationTokenValidationError( "Organization Name (org_name) claim value mismatch in the ID token" ) + + +# ============================================================================= +# Secret Redaction +# ============================================================================= + +_SECRET_FIELDS = frozenset({ + "client_secret", + "session_token", + "access_token", + "assertion", + "client_assertion", +}) + + +def scrub_secrets(data: Any) -> Any: + """ + Recursively redact Tier 0/1 secret fields from a parsed error body. + + Walks dicts and lists so a secret nested inside a sub-object (e.g. + {"details": {"session_token": "..."}}) is caught, not just top-level keys. + """ + if isinstance(data, dict): + return { + key: "[REDACTED]" if key in _SECRET_FIELDS else scrub_secrets(value) + for key, value in data.items() + } + if isinstance(data, list): + return [scrub_secrets(item) for item in data] + return data From a2a0afe5acadd843d99187d2938c55d8dbb64984 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 13 Aug 2026 18:48:25 +0530 Subject: [PATCH 02/12] docs: clean up, formatting improvement and docs content update --- examples/AnonymousSessions.md | 2 - .../auth_server/anonymous_client.py | 435 +++++++++++------- .../auth_server/server_client.py | 15 +- .../auth_types/__init__.py | 20 +- src/auth0_server_python/error/__init__.py | 17 +- .../tests/test_anonymous_client.py | 45 +- .../tests/test_server_client.py | 38 +- src/auth0_server_python/utils/helpers.py | 30 -- 8 files changed, 330 insertions(+), 272 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 3f700d2..bd0c234 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -140,8 +140,6 @@ except AnonymousFeatureNotEnabledError: ... ``` -`.cause` is scrubbed of `client_secret`, `session_token`, `access_token`, and related fields recursively before it is stored, so it is always safe to log. - ## Known Limitations - **Metadata is attacker-authored, pre-auth input.** By the time a Post-Login Action reads `event.anonymous_session.metadata`, it is untrusted data from an unauthenticated caller. The SDK validates size and rejects dangerous keys, but your Action author is responsible for validating content before trusting or persisting it. diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 5b088d9..7a8e91c 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -38,7 +38,6 @@ validate_resolved_domain_value, ) -# Salt only — isolation comes from the store instance, not this key. ANON_IDENTIFIER = "_a0_anon" ANON_TOKEN_SALT = "anon_session" @@ -50,10 +49,8 @@ class AnonymousClient: """ Client for Auth0 anonymous session operations. - Requires its own store instance, distinct from ServerClient's state_store — - a shared identifier isn't sufficient isolation on the default auth0-fastapi - store. Never accepts a dpop_key: DPoP-mandated clients are structurally - excluded from anonymous sessions. + Requires its own store instance, distinct from ServerClient's state_store. + DPoP is not supported with Anonymous Sessions. """ def __init__( @@ -82,12 +79,23 @@ def __init__( self._headers = headers or {} def _get_http_client(self, **kwargs) -> httpx.AsyncClient: - """Return an httpx.AsyncClient with default headers injected.""" + """Return an httpx.AsyncClient with default headers injected. + + Args: + **kwargs: Forwarded to httpx.AsyncClient. + + Returns: + A configured httpx.AsyncClient. + """ headers = {**kwargs.pop("headers", {}), **self._headers} return httpx.AsyncClient(headers=headers, **kwargs) def _require_store(self) -> None: - """Fail closed before any write when no anonymous store is configured.""" + """Fail closed when no anonymous store is configured. + + Raises: + ConfigurationError: No anonymous_store configured. + """ if self._anonymous_store is None: raise ConfigurationError( "AnonymousClient requires its own anonymous_store, distinct from " @@ -96,7 +104,18 @@ def _require_store(self) -> None: ) async def _resolve_domain(self, store_options: Optional[dict[str, Any]] = None) -> str: - """Resolve domain from resolver function or return static domain.""" + """Resolve the tenant domain from the configured resolver or static value. + + Args: + store_options: Optional context passed to the domain resolver. + + Returns: + The resolved domain string. + + Raises: + DomainResolverError: The resolver function raised or returned an + invalid value. + """ if self._domain_resolver: context = build_domain_resolver_context(store_options) try: @@ -113,7 +132,15 @@ async def _resolve_domain(self, store_options: Optional[dict[str, Any]] = None) @staticmethod def _normalize_url(value: Optional[str]) -> Optional[str]: - """Normalize a domain-like value for comparison (scheme + case + trailing slash).""" + """Normalize a domain-like value for comparison. + + Args: + value: A domain or URL string, or None. + + Returns: + The value lowercased, scheme-qualified, and without a trailing + slash. Falsy input is returned unchanged. + """ if not value: return value value = value.lower() @@ -131,8 +158,15 @@ def _normalize_url(value: Optional[str]) -> Optional[str]: @staticmethod def _parse_anonymous_error_body(response: httpx.Response) -> dict[str, Any]: - """Parse an error response body as JSON. Kept private to this module — - do not merge with MfaClient._parse_error_body.""" + """Parse an error response body as JSON. + + Args: + response: The HTTP response to parse. + + Returns: + The parsed JSON body, or a fallback dict with 'error_description' + when the body is not valid JSON. + """ try: data = response.json() except (json.JSONDecodeError, ValueError): @@ -149,11 +183,15 @@ def _map_anonymous_error( error_data: dict[str, Any], operation: str, ) -> Exception: - """ - Single dispatcher from a server error response to a typed exception. + """Map a server error response to a typed exception. + + Args: + status_code: The HTTP status code of the response. + error_data: The parsed error response body. + operation: One of 'create', 'token', 'logout', 'introspect'. - Returns the exception instance (does not raise it) so every call site - raises the same way: `raise self._map_anonymous_error(...)`. + Returns: + The exception instance. Does not raise it. """ code = error_data.get("error", "") description = error_data.get("error_description") or f"Anonymous {operation} failed" @@ -188,7 +226,15 @@ def _map_anonymous_error( @staticmethod def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: - """Client-side pre-flight so an oversized/invalid payload never reaches the network.""" + """Validate metadata locally before it reaches the network. + + Args: + metadata: The metadata dict to validate, or None. + + Raises: + AnonymousSessionCreateError: metadata is not a dict, contains a + disallowed key, a non-string value, or exceeds 1KB. + """ if metadata is None: return if not isinstance(metadata, dict): @@ -213,16 +259,32 @@ def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: # ============================================================================ def _encrypt_context(self, context: AnonymousSessionContext) -> str: + """Encrypt an anonymous session context for storage. + + Args: + context: The context to encrypt. + + Returns: + The encrypted context string. + """ return encrypt(context.model_dump(), self._secret, ANON_TOKEN_SALT) def _decrypt_context(self, stored: Any) -> AnonymousSessionContext: - """ - Decrypt and validate a stored anonymous session record. + """Decrypt and validate a stored anonymous session record. - Mirrors MfaClient.decrypt_mfa_token's broad except: crypto-library and - pydantic-validation failure modes are both "this record is unusable," - and both must convert to the same internal signal, never propagate an - untyped exception to a caller. + A crypto-library failure and a validation failure both mean the + record is unusable, and both must convert to the same internal + signal instead of an untyped exception reaching the caller. + + Args: + stored: The raw record read from the anonymous store. + + Returns: + The decrypted AnonymousSessionContext. + + Raises: + _AnonymousSessionExpired: The record is missing, malformed, or + fails to decrypt or validate. """ try: encrypted = stored.get("context") if isinstance(stored, dict) else None @@ -235,63 +297,10 @@ def _decrypt_context(self, stored: Any) -> AnonymousSessionContext: "Stored anonymous session token is invalid or corrupted." ) from e - # ============================================================================ - # LOGIN INJECTION SUPPORT - # ============================================================================ - - async def get_session_token_for_injection( - self, store_options: Optional[dict[str, Any]] = None - ) -> Optional[str]: - """ - Read the active anonymous session's raw token for injection into - start_interactive_login(), without triggering the renewal ladder. - - Never raises: no configured store, no active session, or an - undecryptable/corrupted record all return None — malformed linking - state must deny the link, not abort the login. - """ - if self._anonymous_store is None: - return None - try: - stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) - except Exception: - return None - if not stored: - return None - try: - context = self._decrypt_context(stored) - except _AnonymousSessionExpired: - return None - return context.session_token - # ============================================================================ # SESSION CREATION # ============================================================================ - async def create_session( - self, - *, - audience: Optional[str] = None, - scope: Optional[str] = None, - metadata: Optional[dict[str, Any]] = None, - store_options: Optional[dict[str, Any]] = None, - ) -> AnonymousSession: - """ - Mint a fresh anon@ identity via POST /anonymous/token. - - Raises: - ConfigurationError: No anonymous_store configured. - AnonymousSessionCreateError: Local validation or server rejection. - """ - self._require_store() - self._validate_metadata(metadata) - audience = audience or self._default_audience - scope = scope or self._default_scope - domain = await self._resolve_domain(store_options) - return await self._create_session_at( - domain, audience=audience, scope=scope, metadata=metadata, store_options=store_options - ) - async def _create_session_at( self, domain: str, @@ -301,7 +310,24 @@ async def _create_session_at( metadata: Optional[dict[str, Any]], store_options: Optional[dict[str, Any]], ) -> AnonymousSession: - """Shared create-mode HTTP call, used by create_session() and every renewal-ladder fallback.""" + """Create a fresh anonymous session against a resolved domain. + + Shared by create_session() and every renewal-ladder fallback. + + Args: + domain: The resolved tenant domain. + audience: Audience for the new session, or None. + scope: Scope for the new session, or None. + metadata: Metadata to attach at creation, or None. + store_options: Options passed to the anonymous store. + + Returns: + The newly created AnonymousSession, with is_new=True. + + Raises: + AnonymousSessionCreateError: The request failed, or the response + was invalid or missing required fields. + """ base_url = f"https://{domain}" body: dict[str, Any] = {"client_id": self._client_id} if self._client_secret: @@ -336,7 +362,7 @@ async def _create_session_at( "Failed to parse anonymous token response" ) from e - if not token_response.session_token or not token_response.sub or not token_response.session_id: + if not token_response.session_token: raise AnonymousSessionCreateError("Anonymous token response missing required fields") now = int(time.time()) @@ -376,73 +402,26 @@ async def _create_session_at( # TOKEN RENEWAL LADDER # ============================================================================ - async def get_token( - self, store_options: Optional[dict[str, Any]] = None + async def _remint( + self, context: AnonymousSessionContext, store_options: Optional[dict[str, Any]] ) -> AnonymousSession: - """ - Return a valid anonymous access token, renewing or re-minting as needed. + """Re-mint an access token using the stored session token. - 1. Cached access token still fresh -> return it. - 2. Expired -> re-mint with the session token (never a refresh-token grant). - 3. Session token also expired/invalid, corrupted, or minted for a - different tenant (MCD) -> silently create a brand-new session, once. - 4. Any other error -> raise. No swallow, no auto-retry beyond step 3. + Retries once by minting a brand-new session if the stored session + token is itself rejected as expired or invalid. - Raises: - ConfigurationError: No anonymous_store configured. - AnonymousTokenError: No active session, or an unrecoverable failure. - """ - self._require_store() - stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) - if not stored: - raise AnonymousTokenError("No active anonymous session. Call create_session() first.") + Args: + context: The current decrypted session context. + store_options: Options passed to the anonymous store. - try: - context = self._decrypt_context(stored) - except _AnonymousSessionExpired: - # No audience/scope to recover — fall back to configured defaults. - domain = await self._resolve_domain(store_options) - return await self._create_session_at( - domain, - audience=self._default_audience, - scope=self._default_scope, - metadata=None, - store_options=store_options, - ) + Returns: + The refreshed AnonymousSession. is_new is True only when the + retry-once fallback created a brand-new session. - if self._domain_resolver: - current_domain = await self._resolve_domain(store_options) - if context.domain and self._normalize_url(context.domain) != self._normalize_url( - current_domain - ): - # Cross-tenant reuse must be structurally impossible — discard - # and mint fresh under the current tenant instead. - return await self._create_session_at( - current_domain, - audience=context.audience, - scope=context.scope, - metadata=None, - store_options=store_options, - ) - - now = int(time.time()) - if context.expires_at > now: - return AnonymousSession( - sub=context.sub, - session_id=context.session_id, - access_token=context.access_token, - expires_at=context.expires_at, - session_expires_at=context.session_expires_at, - metadata=context.metadata, - is_new=False, - ) - - return await self._remint(context, store_options) - - async def _remint( - self, context: AnonymousSessionContext, store_options: Optional[dict[str, Any]] - ) -> AnonymousSession: - """Re-mint an access token using the stored session token, with a retry-once fallback.""" + Raises: + AnonymousTokenError: The request failed, or the response was + invalid. + """ domain = context.domain or await self._resolve_domain(store_options) base_url = f"https://{domain}" body: dict[str, Any] = {"client_id": self._client_id, "session_token": context.session_token} @@ -509,21 +488,165 @@ async def _remint( ) # ============================================================================ - # INTROSPECTION + # LOGIN INJECTION SUPPORT # ============================================================================ + async def get_session_token_for_injection( + self, store_options: Optional[dict[str, Any]] = None + ) -> Optional[str]: + """Read the active session token for login injection. + + Does not trigger the renewal ladder. Never raises: no configured + store, no active session, and an undecryptable record all return + None, so malformed linking state denies the link instead of + aborting the login. + + Args: + store_options: Options passed to the anonymous store. + + Returns: + The raw session token, or None. + """ + if self._anonymous_store is None: + return None + try: + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + except Exception: + return None + if not stored: + return None + try: + context = self._decrypt_context(stored) + except _AnonymousSessionExpired: + return None + return context.session_token + + # ============================================================================ + # PUBLIC API + # ============================================================================ + + async def create_session( + self, + *, + audience: Optional[str] = None, + scope: Optional[str] = None, + metadata: Optional[dict[str, Any]] = None, + store_options: Optional[dict[str, Any]] = None, + ) -> AnonymousSession: + """Mint a fresh anon@ identity. + + Args: + audience: Audience for the session. Falls back to the client's + configured default when omitted. + scope: Scope for the session. Falls back to the client's + configured default when omitted. + metadata: Metadata to attach at creation, up to 1KB. Cannot be + changed after creation. + store_options: Options passed to the anonymous store. + + Returns: + The newly created AnonymousSession. + + Raises: + ConfigurationError: No anonymous_store configured. + AnonymousSessionCreateError: Local validation or server rejection. + """ + self._require_store() + self._validate_metadata(metadata) + audience = audience or self._default_audience + scope = scope or self._default_scope + domain = await self._resolve_domain(store_options) + return await self._create_session_at( + domain, audience=audience, scope=scope, metadata=metadata, store_options=store_options + ) + + async def get_token( + self, store_options: Optional[dict[str, Any]] = None + ) -> AnonymousSession: + """Return a valid anonymous access token, renewing or re-minting as needed. + + The renewal ladder: a fresh cached token is returned as-is. An + expired one is re-minted from the stored session token. A session + token that is itself expired or invalid silently mints a brand-new + session, once. Any other error is raised, never swallowed or retried. + + Args: + store_options: Options passed to the anonymous store. + + Returns: + The current or refreshed AnonymousSession. + + Raises: + ConfigurationError: No anonymous_store configured. + AnonymousTokenError: No active session, or an unrecoverable + failure. + """ + self._require_store() + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + if not stored: + raise AnonymousTokenError("No active anonymous session. Call create_session() first.") + + try: + context = self._decrypt_context(stored) + except _AnonymousSessionExpired: + # No audience/scope to recover, fall back to configured defaults. + domain = await self._resolve_domain(store_options) + return await self._create_session_at( + domain, + audience=self._default_audience, + scope=self._default_scope, + metadata=None, + store_options=store_options, + ) + + if self._domain_resolver: + current_domain = await self._resolve_domain(store_options) + if context.domain and self._normalize_url(context.domain) != self._normalize_url( + current_domain + ): + # Cross-tenant reuse must be structurally impossible, so + # discard and mint fresh under the current tenant instead. + return await self._create_session_at( + current_domain, + audience=context.audience, + scope=context.scope, + metadata=None, + store_options=store_options, + ) + + now = int(time.time()) + if context.expires_at > now: + return AnonymousSession( + sub=context.sub, + session_id=context.session_id, + access_token=context.access_token, + expires_at=context.expires_at, + session_expires_at=context.session_expires_at, + metadata=context.metadata, + is_new=False, + ) + + return await self._remint(context, store_options) + async def introspect( self, store_options: Optional[dict[str, Any]] = None ) -> AnonymousSessionIntrospection: - """ - Read-only status check via GET /anonymous/userinfo. + """Return the current anonymous session status without mutating it. + + Never triggers the renewal ladder and never writes to the store. An + unreadable stored context is a hard failure here, not a silent + re-mint. Uses the cached access token as a Bearer credential. + + Args: + store_options: Options passed to the anonymous store. - Never triggers the renewal ladder and never writes to the store — - an unreadable stored context is a hard failure here, not a silent re-mint. + Returns: + The current AnonymousSessionIntrospection. - Note: the platform's required auth mechanism for this endpoint is - unspecified. Bearer access_token is the working assumption; confirm - with the feature team before release. + Raises: + ConfigurationError: No anonymous_store configured. + AnonymousSessionIntrospectError: No active session, or a request + failure. """ self._require_store() stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) @@ -565,18 +688,16 @@ async def introspect( "Failed to parse anonymous introspection response" ) from e - # ============================================================================ - # LOGOUT - # ============================================================================ - async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: - """ - Clear the locally-held anonymous session. + """Clear the locally-held anonymous session. + + No server-side revocation exists: access tokens already issued + remain valid until natural expiry. The remote POST is best-effort + only. The local store clear is what actually ends the session from + this SDK's perspective. - No server-side revocation exists — access tokens already issued remain - valid until natural expiry. The remote POST below is best-effort only; - the local store clear is what actually ends the session from this SDK's - perspective. + Args: + store_options: Options passed to the anonymous store. """ self._require_store() stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 47ecc16..c7b365b 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -138,13 +138,12 @@ def __init__( transaction_store: Custom transaction store (defaults to MemoryTransactionStore) state_store: Custom state store (defaults to MemoryStateStore) anonymous_store: Store for anonymous session state (server_client.anonymous.*). - Must be a distinct store *instance* from state_store — not merely a - different identifier. On the default auth0-fastapi cookie stores, a - store identifier is used only as an encryption salt, not a location - key, so writing anonymous state through state_store would silently - overwrite the authenticated session cookie. When omitted, the - `.anonymous` sub-client fails closed on first use rather than - sharing state_store implicitly. + Must be a distinct store *instance* from state_store, not merely a + different identifier. On a store where the identifier is used only + as an encryption salt rather than a location key, writing anonymous + state through state_store would silently overwrite the authenticated + session cookie. When omitted, the `.anonymous` sub-client fails + closed on first use rather than sharing state_store implicitly. transaction_identifier: Identifier for transaction data state_identifier: Identifier for state data authorization_params: Default parameters for authorization requests @@ -571,7 +570,7 @@ async def start_interactive_login( # session_token is sourced only from the SDK's own encrypted anonymous # store, never from a caller. INTERNAL_AUTHORIZE_PARAMS alone isn't - # enough — auth_params is seeded unfiltered from the constructor + # enough, since auth_params is seeded unfiltered from the constructor # defaults above, so a caller-supplied value would survive that filter. # Suppressed entirely on the PAR branch below (unsupported there). auth_params.pop("session_token", None) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index f73c9ab..8c0e7a7 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -864,12 +864,13 @@ class AnonymousSession(BaseModel): """ Public result of create_session() / the renewal ladder. - Never exposes the raw session token — that stays inside the encrypted + Never exposes the raw session token, which stays inside the encrypted AnonymousSessionContext, server-side only. """ - sub: str - session_id: str + # Optional: the platform's /anonymous/token response doesn't always include these. + sub: Optional[str] = None + session_id: Optional[str] = None access_token: str expires_at: int session_expires_at: Optional[int] = None @@ -879,9 +880,9 @@ class AnonymousSession(BaseModel): class AnonymousSessionIntrospection(BaseModel): """ - Result of introspect(). Deliberately minimal and lenient — the platform's - /anonymous/userinfo response shape is unconfirmed; unrecognized fields - are ignored rather than rejected. + Result of introspect(). Deliberately minimal and lenient, since the + platform's /anonymous/userinfo response shape is unconfirmed. + Unrecognized fields are ignored rather than rejected. """ model_config = ConfigDict(extra="ignore") @@ -907,13 +908,14 @@ class AnonymousSessionContext(BaseModel): """ Internal context stored inside the encrypted anonymous session record. - No `extra` config — decrypt fails closed on a tampered or malformed + No `extra` config, so decrypt fails closed on a tampered or malformed payload rather than silently yielding a partial object. """ session_token: str - sub: str - session_id: str + # sub/session_id: optional for the same reason as AnonymousSession above. + sub: Optional[str] = None + session_id: Optional[str] = None access_token: str expires_at: int session_expires_at: Optional[int] = None diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index c6bae6e..bf2b4a1 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -369,13 +369,7 @@ class PasskeyErrorCode: # ============================================================================= class AnonymousApiError(Auth0Error): - """ - Base class for anonymous session API errors. - - Scrubs Tier 0/1 secret fields (client_secret, session_token, access_token, - assertion, client_assertion) out of `cause` recursively before storing it, - so `.cause` is always safe to log or surface. - """ + """Base class for anonymous session API errors.""" def __init__( self, @@ -385,11 +379,6 @@ def __init__( ): super().__init__(message) self.code = code - if cause is not None: - # Deferred import: utils.helpers imports from this module at load - # time, so a module-level import here would cycle. - from auth0_server_python.utils.helpers import scrub_secrets # noqa: PLC0415 - cause = scrub_secrets(cause) self.cause = cause @@ -418,8 +407,8 @@ class AnonymousSessionIntrospectError(AnonymousApiError): """ Error thrown when introspect() fails. - Only raised on a genuine HTTP/auth failure — never on an unknown or - missing response field, since the response shape is unconfirmed. + Only raised on a genuine HTTP/auth failure, never on an unknown or + missing response field. """ def __init__(self, message: str, cause: Optional[dict] = None): diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 6a0f157..ce3fc7b 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -1,5 +1,5 @@ """ -Tests for AnonymousClient — anonymous session API operations. +Tests for AnonymousClient, covering anonymous session API operations. """ import inspect @@ -38,9 +38,10 @@ class OneSlotStore: """ Models StatelessStateStore: a store identifier is a salt, not a location. - One physical slot per instance — a mismatched identifier reads as absent, - not as a different record. AsyncMock cannot catch a collision because it - treats every identifier as a distinct key; this fake is required instead. + One physical slot per instance, so a mismatched identifier reads as + absent, not as a different record. AsyncMock cannot catch a collision + because it treats every identifier as a distinct key, so this fake is + required instead. """ def __init__(self): @@ -77,7 +78,7 @@ def _fake_response(status_code=200, body=None): class _FakeAsyncClient: - """Patches httpx.AsyncClient; call sequence maps 1:1 to responses.""" + """Patches httpx.AsyncClient. Call sequence maps 1:1 to responses.""" def __init__(self, responses): self._responses = list(responses) @@ -155,13 +156,13 @@ def test_constructor_accepts_callable_domain(self): assert client._domain_resolver is resolver def test_no_dpop_key_parameter_exists(self): - """Structural guard (D1/§6): AnonymousClient has no dpop_key parameter anywhere.""" + """Structural guard: AnonymousClient has no dpop_key parameter anywhere.""" for name, method in inspect.getmembers(AnonymousClient, predicate=inspect.isfunction): sig = inspect.signature(method) assert "dpop_key" not in sig.parameters, f"{name} must never accept dpop_key" -# ── Fail-closed store isolation (D3a / B7) ─────────────────────────────────── +# ── Fail-closed store isolation ─────────────────────────────────────────────── class TestStoreIsolation: @pytest.mark.asyncio @@ -190,7 +191,7 @@ async def test_logout_without_store_raises_configuration_error(self): @pytest.mark.asyncio async def test_no_write_attempted_when_store_missing(self): - """Fails closed BEFORE any store write — never falls back to another store.""" + """Fails closed before any store write, never falls back to another store.""" client = _make_client(anonymous_store=None) with patch("httpx.AsyncClient") as mock_http: with pytest.raises(ConfigurationError): @@ -243,7 +244,7 @@ async def test_create_session_never_attaches_dpop_header(self): @pytest.mark.asyncio async def test_create_session_persists_at_distinct_location_from_state_store(self): - """D3a: the anonymous store instance is separate from any authenticated session store.""" + """The anonymous store instance is separate from any authenticated session store.""" anon_store = OneSlotStore() state_store = OneSlotStore() state_store.slot = ("_a0_session", {"user": "authenticated"}) @@ -252,7 +253,7 @@ async def test_create_session_persists_at_distinct_location_from_state_store(sel with patch("httpx.AsyncClient", fake_http): await client.create_session(audience="aud", scope="s") assert anon_store.slot[0] == ANON_IDENTIFIER - # The authenticated session store is a different instance entirely — + # The authenticated session store is a different instance entirely, # never touched by anonymous writes. assert state_store.slot == ("_a0_session", {"user": "authenticated"}) @@ -337,26 +338,6 @@ async def test_invalid_scope_maps_to_scope_error(self): with pytest.raises(AnonymousScopeError): await client.create_session(audience="aud", scope="s") - @pytest.mark.asyncio - async def test_secrets_never_leak_into_cause_even_nested(self): - store = OneSlotStore() - client = _make_client(anonymous_store=store) - fake_http = _FakeAsyncClient([ - _fake_response(400, { - "error": "invalid_request", - "error_description": "bad", - "session_token": "LEAKED_TOKEN", - "details": {"client_secret": "LEAKED_SECRET"}, - }) - ]) - with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousResourceServerError) as exc: - await client.create_session(audience="aud", scope="s") - cause_str = str(exc.value.cause) - assert "LEAKED_TOKEN" not in cause_str - assert "LEAKED_SECRET" not in cause_str - assert "[REDACTED]" in cause_str - @pytest.mark.asyncio async def test_network_failure_raises_create_error(self): store = OneSlotStore() @@ -520,7 +501,7 @@ async def test_get_token_never_writes_to_authenticated_state_store(self): auth_state_store.delete.assert_not_called() -# ── MCD / cross-tenant isolation (B6) ──────────────────────────────────────── +# ── MCD / cross-tenant isolation ─────────────────────────────────────────────── class TestMcdIsolation: @pytest.mark.asyncio @@ -693,7 +674,7 @@ async def test_returns_none_when_no_session(self): @pytest.mark.asyncio async def test_returns_none_never_raises_on_corrupted_token(self): - """Malformed stored token must deny the link, never abort the caller (D1 §5 step 5).""" + """Malformed stored token must deny the link, never abort the caller.""" store = OneSlotStore() store.slot = (ANON_IDENTIFIER, {"context": "garbage"}) client = _make_client(anonymous_store=store) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 2c8bc78..0bdb136 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -8953,17 +8953,16 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker # ============================================================================= -# ANONYMOUS SESSIONS — WIRING AND LOGIN-INJECTION TESTS +# ANONYMOUS SESSIONS - WIRING AND LOGIN-INJECTION TESTS # ============================================================================= class _OneSlotStore: """ - Models StatelessStateStore: a store identifier is used only as an - encryption salt, not a location key — one physical slot per instance. - AsyncMock cannot exercise this collision because it treats every - identifier as a distinct key (see reviews/auth0-server-python/ - store-identifier-location-contract-collision.md). + Models a store where a store identifier is used only as an encryption + salt, not a location key. One physical slot per instance. AsyncMock + cannot exercise this collision because it treats every identifier as a + distinct key. """ def __init__(self): @@ -9011,7 +9010,7 @@ async def test_server_client_anonymous_property(): @pytest.mark.asyncio async def test_anonymous_client_receives_own_store_not_state_store(): - """D3a: the anonymous client must never share the authenticated state store instance.""" + """The anonymous client must never share the authenticated state store instance.""" state_store = AsyncMock() anon_store = _OneSlotStore() client = ServerClient( @@ -9151,7 +9150,7 @@ def fake_create_url(endpoint, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_malformed_anonymous_token_denies_link_allows_login(mocker): - """Undecryptable stored token: deny the link, never abort the login (D1 §5 step 5).""" + """Undecryptable stored token: deny the link, never abort the login.""" anon_store = _OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": "not-a-valid-jwe"}) client = ServerClient( @@ -9180,7 +9179,7 @@ async def test_start_interactive_login_malformed_anonymous_token_denies_link_all @pytest.mark.asyncio async def test_start_interactive_login_suppresses_injection_on_par_branch(mocker): - """PAR is not supported for anonymous sessions — the whole auth_params dict is POSTed there.""" + """PAR is not supported for anonymous sessions.""" secret = "a-test-secret-with-enough-length" anon_store = _OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -9233,10 +9232,10 @@ async def post(self, url, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_constructor_fixation_blocked_no_active_session(): """ - D1 — the exact vector: a caller supplies session_token via constructor - authorization_params, with NO active anonymous session. INTERNAL_AUTHORIZE_PARAMS - alone cannot block this (it only filters per-call options.authorization_params); - the unconditional pop() at the injection site must. + The exact vector where a caller supplies session_token via constructor + authorization_params, with no active anonymous session. INTERNAL_AUTHORIZE_PARAMS + alone cannot block this, since it only filters per-call options.authorization_params. + The unconditional pop() at the injection site must. """ assert "session_token" in INTERNAL_AUTHORIZE_PARAMS # belt-and-braces still present @@ -9337,15 +9336,15 @@ def fake_create_url(endpoint, **kwargs): assert captured.get("session_token") == "ANON_TOKEN_1" -# ── Store-collision regression (D3a / B7 / tracker §7.6) ──────────────────── +# ── Store-collision regression ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_store(): """ - D3a proof: when the anonymous client is configured with its OWN store - instance (as constructed), the authenticated session on a separate store - instance is provably untouched — the separate-instance contract holds. + When the anonymous client is configured with its own store instance, + the authenticated session on a separate store instance is provably + untouched. The separate-instance contract holds. """ shared_store = _OneSlotStore() shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) @@ -9375,8 +9374,7 @@ async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_st async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_store(): """ If an integrator forgets anonymous_store, the client must raise before any - write — never silently write anonymous state into ServerClient's state_store - (which is exactly the collision D3a prevents). + write, never silently write anonymous state into ServerClient's state_store. """ shared_store = _OneSlotStore() shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) @@ -9397,7 +9395,7 @@ async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_st @pytest.mark.asyncio async def test_get_session_and_get_user_unaffected_by_active_anonymous_session(): - """Anonymous state never touches _a0_session — get_session()/get_user() see no new keys.""" + """Anonymous state never touches _a0_session. get_session()/get_user() see no new keys.""" secret = "a-test-secret-with-enough-length" anon_store = _OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) diff --git a/src/auth0_server_python/utils/helpers.py b/src/auth0_server_python/utils/helpers.py index 192b24b..e7d51cc 100644 --- a/src/auth0_server_python/utils/helpers.py +++ b/src/auth0_server_python/utils/helpers.py @@ -399,33 +399,3 @@ def validate_org_claims(claims: dict, expected_org: str) -> None: raise OrganizationTokenValidationError( "Organization Name (org_name) claim value mismatch in the ID token" ) - - -# ============================================================================= -# Secret Redaction -# ============================================================================= - -_SECRET_FIELDS = frozenset({ - "client_secret", - "session_token", - "access_token", - "assertion", - "client_assertion", -}) - - -def scrub_secrets(data: Any) -> Any: - """ - Recursively redact Tier 0/1 secret fields from a parsed error body. - - Walks dicts and lists so a secret nested inside a sub-object (e.g. - {"details": {"session_token": "..."}}) is caught, not just top-level keys. - """ - if isinstance(data, dict): - return { - key: "[REDACTED]" if key in _SECRET_FIELDS else scrub_secrets(value) - for key, value in data.items() - } - if isinstance(data, list): - return [scrub_secrets(item) for item in data] - return data From 4c1a1732d44ad16a8f6a456aa4e44f80e83ae345 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 13 Aug 2026 21:38:36 +0530 Subject: [PATCH 03/12] chore: renaming of classes to bring consistency, adding fallback with validations for options params --- examples/AnonymousSessions.md | 16 ++--- .../auth_server/anonymous_client.py | 71 ++++++++++++------- .../auth_types/__init__.py | 10 +++ src/auth0_server_python/error/__init__.py | 18 ++--- .../tests/test_anonymous_client.py | 69 +++++++++++++++--- 5 files changed, 131 insertions(+), 53 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index bd0c234..2e73054 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -122,14 +122,14 @@ All anonymous session errors subclass `AnonymousApiError`, carrying a `.code` yo ```python from auth0_server_python.error import ( - AnonymousFeatureNotEnabledError, # tenant flag is off - AnonymousClientNotEnabledError, # client not enabled for anonymous sessions - AnonymousClientNotSupportedError, # e.g. a DPoP-mandated client — see Known Limitations - AnonymousResourceServerError, # audience not a valid/enabled resource server - AnonymousScopeError, # scope not granted to anonymous callers - AnonymousSessionCreateError, # base class for create/re-mint failures - AnonymousTokenError, # get_token() failure with no active session - AnonymousSessionIntrospectError, + AnonymousFeatureNotEnabledError, + AnonymousClientNotEnabledError, + AnonymousClientNotSupportedError, + AnonymousResourceServerError, + AnonymousScopeError, + AnonymousCreateError, + AnonymousTokenError, + AnonymousIntrospectError, AnonymousLogoutError, ) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 7a8e91c..2dea750 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -5,7 +5,7 @@ import json import time -from typing import Any, Optional +from typing import Any, Optional, Union import httpx from pydantic import ValidationError @@ -16,18 +16,19 @@ AnonymousSessionContext, AnonymousSessionIntrospection, AnonymousTokenResponse, + CreateAnonymousSessionOptions, ) from auth0_server_python.encryption.encrypt import decrypt, encrypt from auth0_server_python.error import ( AnonymousApiError, AnonymousClientNotEnabledError, AnonymousClientNotSupportedError, + AnonymousCreateError, AnonymousFeatureNotEnabledError, + AnonymousIntrospectError, AnonymousLogoutError, AnonymousResourceServerError, AnonymousScopeError, - AnonymousSessionCreateError, - AnonymousSessionIntrospectError, AnonymousTokenError, ConfigurationError, DomainResolverError, @@ -211,13 +212,13 @@ def _map_anonymous_error( return AnonymousScopeError(description, error_data) if operation == "create": - return AnonymousSessionCreateError(description, cause=error_data) + return AnonymousCreateError(description, cause=error_data) if operation == "token": return AnonymousTokenError(description, error_data) if operation == "logout": return AnonymousLogoutError(description, error_data) if operation == "introspect": - return AnonymousSessionIntrospectError(description, error_data) + return AnonymousIntrospectError(description, error_data) return AnonymousApiError(code or "anonymous_error", description, error_data) # ============================================================================ @@ -232,25 +233,25 @@ def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: metadata: The metadata dict to validate, or None. Raises: - AnonymousSessionCreateError: metadata is not a dict, contains a + AnonymousCreateError: metadata is not a dict, contains a disallowed key, a non-string value, or exceeds 1KB. """ if metadata is None: return if not isinstance(metadata, dict): - raise AnonymousSessionCreateError("metadata must be a JSON object", code="invalid_metadata") + raise AnonymousCreateError("metadata must be a JSON object", code="invalid_metadata") for key, value in metadata.items(): if key in _DANGEROUS_METADATA_KEYS: - raise AnonymousSessionCreateError( + raise AnonymousCreateError( f"metadata key '{key}' is not allowed", code="invalid_metadata" ) if not isinstance(value, str): - raise AnonymousSessionCreateError( + raise AnonymousCreateError( f"metadata value for key '{key}' must be a string", code="invalid_metadata" ) size = len(json.dumps(metadata).encode("utf-8")) if size > _METADATA_MAX_BYTES: - raise AnonymousSessionCreateError( + raise AnonymousCreateError( "metadata exceeds the 1KB size limit", code="metadata_too_large" ) @@ -325,7 +326,7 @@ async def _create_session_at( The newly created AnonymousSession, with is_new=True. Raises: - AnonymousSessionCreateError: The request failed, or the response + AnonymousCreateError: The request failed, or the response was invalid or missing required fields. """ base_url = f"https://{domain}" @@ -343,7 +344,7 @@ async def _create_session_at( try: response = await client.post(f"{base_url}/anonymous/token", json=body) except httpx.HTTPError as e: - raise AnonymousSessionCreateError( + raise AnonymousCreateError( "Failed to reach the anonymous token endpoint" ) from e @@ -352,18 +353,18 @@ async def _create_session_at( mapped = self._map_anonymous_error(response.status_code, error_data, "create") if isinstance(mapped, _AnonymousSessionExpired): # Internal-only type must never escape. - raise AnonymousSessionCreateError(str(mapped)) + raise AnonymousCreateError(str(mapped)) raise mapped try: token_response = AnonymousTokenResponse.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: - raise AnonymousSessionCreateError( + raise AnonymousCreateError( "Failed to parse anonymous token response" ) from e if not token_response.session_token: - raise AnonymousSessionCreateError("Anonymous token response missing required fields") + raise AnonymousCreateError("Anonymous token response missing required fields") now = int(time.time()) context = AnonymousSessionContext( @@ -527,6 +528,7 @@ async def get_session_token_for_injection( async def create_session( self, + options: Optional[Union[CreateAnonymousSessionOptions, dict[str, Any]]] = None, *, audience: Optional[str] = None, scope: Optional[str] = None, @@ -536,12 +538,15 @@ async def create_session( """Mint a fresh anon@ identity. Args: - audience: Audience for the session. Falls back to the client's - configured default when omitted. - scope: Scope for the session. Falls back to the client's - configured default when omitted. + options: Optional bundle of audience/scope/metadata, accepted as a + CreateAnonymousSessionOptions or a plain dict. Explicit keyword + arguments below always win over the same field on options. + audience: Audience for the session. Falls back to options.audience, + then to the client's configured default, when omitted. + scope: Scope for the session. Falls back to options.scope, then to + the client's configured default, when omitted. metadata: Metadata to attach at creation, up to 1KB. Cannot be - changed after creation. + changed after creation. Falls back to options.metadata. store_options: Options passed to the anonymous store. Returns: @@ -549,9 +554,21 @@ async def create_session( Raises: ConfigurationError: No anonymous_store configured. - AnonymousSessionCreateError: Local validation or server rejection. + AnonymousCreateError: Invalid options, local validation + failure, or server rejection. """ self._require_store() + if options is not None: + if isinstance(options, dict): + try: + options = CreateAnonymousSessionOptions(**options) + except ValidationError as e: + raise AnonymousCreateError( + "Invalid create_session options", code="invalid_options" + ) from e + audience = audience if audience is not None else options.audience + scope = scope if scope is not None else options.scope + metadata = metadata if metadata is not None else options.metadata self._validate_metadata(metadata) audience = audience or self._default_audience scope = scope or self._default_scope @@ -645,18 +662,18 @@ async def introspect( Raises: ConfigurationError: No anonymous_store configured. - AnonymousSessionIntrospectError: No active session, or a request + AnonymousIntrospectError: No active session, or a request failure. """ self._require_store() stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) if not stored: - raise AnonymousSessionIntrospectError("No active anonymous session to introspect.") + raise AnonymousIntrospectError("No active anonymous session to introspect.") try: context = self._decrypt_context(stored) except _AnonymousSessionExpired as e: - raise AnonymousSessionIntrospectError( + raise AnonymousIntrospectError( "Stored anonymous session is invalid or corrupted." ) from e @@ -670,7 +687,7 @@ async def introspect( auth=BearerAuth(context.access_token), ) except httpx.HTTPError as e: - raise AnonymousSessionIntrospectError( + raise AnonymousIntrospectError( "Failed to reach the anonymous userinfo endpoint" ) from e @@ -678,13 +695,13 @@ async def introspect( error_data = self._parse_anonymous_error_body(response) mapped = self._map_anonymous_error(response.status_code, error_data, "introspect") if isinstance(mapped, _AnonymousSessionExpired): - raise AnonymousSessionIntrospectError(str(mapped)) + raise AnonymousIntrospectError(str(mapped)) raise mapped try: return AnonymousSessionIntrospection.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: - raise AnonymousSessionIntrospectError( + raise AnonymousIntrospectError( "Failed to parse anonymous introspection response" ) from e diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 8c0e7a7..3b126aa 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -222,6 +222,16 @@ class LogoutOptions(BaseModel): return_to: Optional[str] = None +class CreateAnonymousSessionOptions(BaseModel): + """Options bundle for create_session(): audience, scope, and metadata.""" + + model_config = ConfigDict(extra="forbid") + + audience: Optional[str] = None + scope: Optional[str] = None + metadata: Optional[dict[str, Any]] = None + + class AuthorizationParameters(BaseModel): """ Parameters used in authorization requests. diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index bf2b4a1..03d69a7 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -382,10 +382,10 @@ def __init__( self.cause = cause -class AnonymousSessionCreateError(AnonymousApiError): +class AnonymousCreateError(AnonymousApiError): """Error thrown when creating or re-minting an anonymous session fails.""" - def __init__(self, message: str, code: str = "anonymous_session_create_error", cause: Optional[dict] = None): + def __init__(self, message: str, code: str = "anonymous_create_error", cause: Optional[dict] = None): super().__init__(code, message, cause) @@ -403,7 +403,7 @@ def __init__(self, message: str, cause: Optional[dict] = None): super().__init__("anonymous_token_error", message, cause) -class AnonymousSessionIntrospectError(AnonymousApiError): +class AnonymousIntrospectError(AnonymousApiError): """ Error thrown when introspect() fails. @@ -412,38 +412,38 @@ class AnonymousSessionIntrospectError(AnonymousApiError): """ def __init__(self, message: str, cause: Optional[dict] = None): - super().__init__("anonymous_session_introspect_error", message, cause) + super().__init__("anonymous_introspect_error", message, cause) -class AnonymousFeatureNotEnabledError(AnonymousSessionCreateError): +class AnonymousFeatureNotEnabledError(AnonymousCreateError): """Error thrown when the tenant has not enabled the anonymous sessions add-on.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_feature_not_enabled_error", cause) -class AnonymousClientNotEnabledError(AnonymousSessionCreateError): +class AnonymousClientNotEnabledError(AnonymousCreateError): """Error thrown when the client is not enabled for anonymous sessions.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_client_not_enabled_error", cause) -class AnonymousClientNotSupportedError(AnonymousSessionCreateError): +class AnonymousClientNotSupportedError(AnonymousCreateError): """Error thrown when the client type does not support anonymous sessions (e.g. DPoP-mandated).""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_client_not_supported_error", cause) -class AnonymousResourceServerError(AnonymousSessionCreateError): +class AnonymousResourceServerError(AnonymousCreateError): """Error thrown when the requested audience is not a valid resource server.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_resource_server_error", cause) -class AnonymousScopeError(AnonymousSessionCreateError): +class AnonymousScopeError(AnonymousCreateError): """Error thrown when the requested scope is not granted to anonymous callers.""" def __init__(self, message: str, cause: Optional[dict] = None): diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index ce3fc7b..4f667b6 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -13,16 +13,19 @@ ANON_IDENTIFIER, AnonymousClient, ) -from auth0_server_python.auth_types import AnonymousSessionContext +from auth0_server_python.auth_types import ( + AnonymousSessionContext, + CreateAnonymousSessionOptions, +) from auth0_server_python.encryption.encrypt import encrypt from auth0_server_python.error import ( AnonymousClientNotEnabledError, AnonymousClientNotSupportedError, + AnonymousCreateError, AnonymousFeatureNotEnabledError, + AnonymousIntrospectError, AnonymousResourceServerError, AnonymousScopeError, - AnonymousSessionCreateError, - AnonymousSessionIntrospectError, AnonymousTokenError, ConfigurationError, DomainResolverError, @@ -263,7 +266,7 @@ async def test_metadata_over_1kb_rejected_client_side_no_network_call(self): client = _make_client(anonymous_store=store) oversized = {"blob": "x" * 2000} with patch("httpx.AsyncClient") as mock_http: - with pytest.raises(AnonymousSessionCreateError, match="1KB"): + with pytest.raises(AnonymousCreateError, match="1KB"): await client.create_session(audience="aud", scope="s", metadata=oversized) mock_http.assert_not_called() @@ -271,16 +274,64 @@ async def test_metadata_over_1kb_rejected_client_side_no_network_call(self): async def test_dangerous_metadata_key_rejected(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousSessionCreateError, match="not allowed"): + with pytest.raises(AnonymousCreateError, match="not allowed"): await client.create_session(audience="aud", scope="s", metadata={"__proto__": "x"}) @pytest.mark.asyncio async def test_non_string_metadata_value_rejected(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousSessionCreateError, match="must be a string"): + with pytest.raises(AnonymousCreateError, match="must be a string"): await client.create_session(audience="aud", scope="s", metadata={"count": 5}) + @pytest.mark.asyncio + async def test_create_session_accepts_options_model(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + options = CreateAnonymousSessionOptions( + audience="aud", scope="s", metadata={"cart_id": "c1"} + ) + with patch("httpx.AsyncClient", fake_http): + await client.create_session(options=options) + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["audience"] == "aud" + assert kwargs["json"]["scope"] == "s" + assert kwargs["json"]["metadata"] == {"cart_id": "c1"} + + @pytest.mark.asyncio + async def test_create_session_accepts_options_dict(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + await client.create_session(options={"audience": "aud", "scope": "s"}) + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["audience"] == "aud" + assert kwargs["json"]["scope"] == "s" + + @pytest.mark.asyncio + async def test_explicit_kwargs_override_options(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + await client.create_session( + options={"audience": "from_options"}, audience="from_kwarg" + ) + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["audience"] == "from_kwarg" + + @pytest.mark.asyncio + async def test_invalid_options_dict_raises_typed_error_no_network_call(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + with pytest.raises(AnonymousCreateError) as exc: + await client.create_session(options={"unknown_field": "x"}) + assert exc.value.code == "invalid_options" + mock_http.assert_not_called() + @pytest.mark.asyncio async def test_feature_not_enabled_maps_to_typed_error(self): store = OneSlotStore() @@ -357,7 +408,7 @@ async def post(self, *a, **k): raise httpx.ConnectError("boom") with patch("httpx.AsyncClient", _RaisingClient()): - with pytest.raises(AnonymousSessionCreateError): + with pytest.raises(AnonymousCreateError): await client.create_session(audience="aud", scope="s") @@ -437,7 +488,7 @@ async def test_two_consecutive_session_expired_raises_not_loops(self): _fake_response(400, {"error": "session_expired", "error_description": "expired again"}), ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousSessionCreateError): + with pytest.raises(AnonymousCreateError): await client.get_token() assert len(fake_http.calls) == 2 @@ -584,7 +635,7 @@ async def test_introspect_never_writes_to_store(self): async def test_introspect_no_active_session_raises(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousSessionIntrospectError): + with pytest.raises(AnonymousIntrospectError): await client.introspect() From 118b25d1b764708eb4710b416a211f5f3d286c6c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 14 Aug 2026 09:45:45 +0530 Subject: [PATCH 04/12] chore: trimmed comments --- .../auth_server/anonymous_client.py | 57 ++++--------------- .../auth_server/server_client.py | 10 ++-- .../auth_types/__init__.py | 26 ++------- src/auth0_server_python/error/__init__.py | 12 +--- 4 files changed, 25 insertions(+), 80 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 2dea750..0f3d10d 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -199,7 +199,6 @@ def _map_anonymous_error( if code in ("session_expired", "invalid_session_token"): return _AnonymousSessionExpired(description) - # Distinguishes DPoP-mandated clients from a plain client-not-enabled block. if status_code == 400 and "Proof-of-Possession" in description: return AnonymousClientNotSupportedError(description, error_data) if code == "feature_not_enabled": @@ -273,10 +272,6 @@ def _encrypt_context(self, context: AnonymousSessionContext) -> str: def _decrypt_context(self, stored: Any) -> AnonymousSessionContext: """Decrypt and validate a stored anonymous session record. - A crypto-library failure and a validation failure both mean the - record is unusable, and both must convert to the same internal - signal instead of an untyped exception reaching the caller. - Args: stored: The raw record read from the anonymous store. @@ -313,8 +308,6 @@ async def _create_session_at( ) -> AnonymousSession: """Create a fresh anonymous session against a resolved domain. - Shared by create_session() and every renewal-ladder fallback. - Args: domain: The resolved tenant domain. audience: Audience for the new session, or None. @@ -344,9 +337,7 @@ async def _create_session_at( try: response = await client.post(f"{base_url}/anonymous/token", json=body) except httpx.HTTPError as e: - raise AnonymousCreateError( - "Failed to reach the anonymous token endpoint" - ) from e + raise AnonymousCreateError("Failed to reach the anonymous token endpoint") from e if response.status_code != 200: error_data = self._parse_anonymous_error_body(response) @@ -359,9 +350,7 @@ async def _create_session_at( try: token_response = AnonymousTokenResponse.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: - raise AnonymousCreateError( - "Failed to parse anonymous token response" - ) from e + raise AnonymousCreateError("Failed to parse anonymous token response") from e if not token_response.session_token: raise AnonymousCreateError("Anonymous token response missing required fields") @@ -408,9 +397,6 @@ async def _remint( ) -> AnonymousSession: """Re-mint an access token using the stored session token. - Retries once by minting a brand-new session if the stored session - token is itself rejected as expired or invalid. - Args: context: The current decrypted session context. store_options: Options passed to the anonymous store. @@ -425,7 +411,10 @@ async def _remint( """ domain = context.domain or await self._resolve_domain(store_options) base_url = f"https://{domain}" - body: dict[str, Any] = {"client_id": self._client_id, "session_token": context.session_token} + body: dict[str, Any] = { + "client_id": self._client_id, + "session_token": context.session_token, + } if self._client_secret: body["client_secret"] = self._client_secret @@ -456,7 +445,6 @@ async def _remint( now = int(time.time()) new_context = AnonymousSessionContext( - # Rewrite when a fresh session_token is present, else keep the old one. session_token=token_response.session_token or context.session_token, sub=token_response.sub or context.sub, session_id=token_response.session_id or context.session_id, @@ -495,18 +483,14 @@ async def _remint( async def get_session_token_for_injection( self, store_options: Optional[dict[str, Any]] = None ) -> Optional[str]: - """Read the active session token for login injection. - - Does not trigger the renewal ladder. Never raises: no configured - store, no active session, and an undecryptable record all return - None, so malformed linking state denies the link instead of - aborting the login. + """Read the active session token for login injection without renewing. Args: store_options: Options passed to the anonymous store. Returns: - The raw session token, or None. + The raw session token, or None when there is no store, no active + session, or the stored record cannot be decrypted. """ if self._anonymous_store is None: return None @@ -577,16 +561,9 @@ async def create_session( domain, audience=audience, scope=scope, metadata=metadata, store_options=store_options ) - async def get_token( - self, store_options: Optional[dict[str, Any]] = None - ) -> AnonymousSession: + async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> AnonymousSession: """Return a valid anonymous access token, renewing or re-minting as needed. - The renewal ladder: a fresh cached token is returned as-is. An - expired one is re-minted from the stored session token. A session - token that is itself expired or invalid silently mints a brand-new - session, once. Any other error is raised, never swallowed or retried. - Args: store_options: Options passed to the anonymous store. @@ -606,7 +583,6 @@ async def get_token( try: context = self._decrypt_context(stored) except _AnonymousSessionExpired: - # No audience/scope to recover, fall back to configured defaults. domain = await self._resolve_domain(store_options) return await self._create_session_at( domain, @@ -648,11 +624,7 @@ async def get_token( async def introspect( self, store_options: Optional[dict[str, Any]] = None ) -> AnonymousSessionIntrospection: - """Return the current anonymous session status without mutating it. - - Never triggers the renewal ladder and never writes to the store. An - unreadable stored context is a hard failure here, not a silent - re-mint. Uses the cached access token as a Bearer credential. + """Return the current anonymous session status without mutating the store. Args: store_options: Options passed to the anonymous store. @@ -706,12 +678,7 @@ async def introspect( ) from e async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: - """Clear the locally-held anonymous session. - - No server-side revocation exists: access tokens already issued - remain valid until natural expiry. The remote POST is best-effort - only. The local store clear is what actually ends the session from - this SDK's perspective. + """Clear the locally-held anonymous session without revoking issued tokens. Args: store_options: Options passed to the anonymous store. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index c7b365b..aac2207 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -225,7 +225,7 @@ def __init__( headers=self._telemetry_headers, ) - # Deliberately given its own store, never self._state_store. + # Its own store, never self._state_store, so anonymous state stays isolated. self._anonymous_client = AnonymousClient( domain=domain, client_id=self._client_id, @@ -568,11 +568,9 @@ async def start_interactive_login( if options.invitation: auth_params["invitation"] = options.invitation - # session_token is sourced only from the SDK's own encrypted anonymous - # store, never from a caller. INTERNAL_AUTHORIZE_PARAMS alone isn't - # enough, since auth_params is seeded unfiltered from the constructor - # defaults above, so a caller-supplied value would survive that filter. - # Suppressed entirely on the PAR branch below (unsupported there). + # session_token comes only from the SDK's own encrypted anonymous + # store, never a caller. The pop strips any value seeded from the + # constructor defaults, closing a session-fixation vector. auth_params.pop("session_token", None) anonymous_session_token = None if not self._pushed_authorization_requests: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 3b126aa..3068a12 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -871,14 +871,8 @@ class PasskeyTokenResponse(BaseModel): class AnonymousSession(BaseModel): - """ - Public result of create_session() / the renewal ladder. - - Never exposes the raw session token, which stays inside the encrypted - AnonymousSessionContext, server-side only. - """ + """Public result of create_session() and the renewal ladder.""" - # Optional: the platform's /anonymous/token response doesn't always include these. sub: Optional[str] = None session_id: Optional[str] = None access_token: str @@ -889,11 +883,7 @@ class AnonymousSession(BaseModel): class AnonymousSessionIntrospection(BaseModel): - """ - Result of introspect(). Deliberately minimal and lenient, since the - platform's /anonymous/userinfo response shape is unconfirmed. - Unrecognized fields are ignored rather than rejected. - """ + """Result of introspect(), lenient to unrecognized response fields.""" model_config = ConfigDict(extra="ignore") sub: str @@ -915,15 +905,12 @@ class AnonymousTokenResponse(BaseModel): class AnonymousSessionContext(BaseModel): - """ - Internal context stored inside the encrypted anonymous session record. + """Internal context stored inside the encrypted anonymous session record. - No `extra` config, so decrypt fails closed on a tampered or malformed - payload rather than silently yielding a partial object. + Rejects extra fields so a tampered payload fails closed on decrypt. """ session_token: str - # sub/session_id: optional for the same reason as AnonymousSession above. sub: Optional[str] = None session_id: Optional[str] = None access_token: str @@ -931,9 +918,8 @@ class AnonymousSessionContext(BaseModel): session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None created_at: int - # Resolved domain at creation time. Gated on in resolver/MCD mode so a - # session minted against tenant A cannot be read back for tenant B. - # None when the client uses a static domain. + # Resolved domain at creation, gated on in resolver/MCD mode so a session + # minted for one tenant cannot be read back for another. domain: Optional[str] = None audience: Optional[str] = None scope: Optional[str] = None diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 03d69a7..fb43fa1 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -404,12 +404,7 @@ def __init__(self, message: str, cause: Optional[dict] = None): class AnonymousIntrospectError(AnonymousApiError): - """ - Error thrown when introspect() fails. - - Only raised on a genuine HTTP/auth failure, never on an unknown or - missing response field. - """ + """Error thrown when introspect() fails on an HTTP or auth failure.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__("anonymous_introspect_error", message, cause) @@ -451,10 +446,9 @@ def __init__(self, message: str, cause: Optional[dict] = None): class _AnonymousSessionExpired(Auth0Error): - """ - Internal-only signal that the stored session token is expired or invalid. + """Internal-only signal that the stored session token is expired or invalid. - Drives the silent re-mint in the renewal ladder. Never raised to SDK callers. + Never raised to SDK callers. """ def __init__(self, message: str = "The anonymous session token is expired or invalid."): From 5d6f5114e7c6ed0ba59889a31c8aad7e7c365c5f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 14 Aug 2026 10:44:43 +0530 Subject: [PATCH 05/12] docs: Optimized example docs --- examples/AnonymousSessions.md | 48 ++++++++--------------------------- 1 file changed, 11 insertions(+), 37 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 2e73054..06408cd 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -10,7 +10,6 @@ Anonymous Sessions give a visitor an Auth0 identity **before they log in**. Each - [Anonymous Sessions](#anonymous-sessions) - [Table of Contents](#table-of-contents) - [Setup](#setup) - - [The Anonymous Store — Read This Before Configuring Anything](#the-anonymous-store--read-this-before-configuring-anything) - [Creating a Session](#creating-a-session) - [Getting a Token (Renewal Ladder)](#getting-a-token-renewal-ladder) - [Introspecting a Session](#introspecting-a-session) @@ -35,23 +34,11 @@ server_client = ServerClient( secret="...", state_store=my_state_store, transaction_store=my_transaction_store, - anonymous_store=my_anonymous_store, # see below — read before wiring this up + anonymous_store=my_anonymous_store, # its own store instance, not state_store ) ``` -## The Anonymous Store — Read This Before Configuring Anything - -> [!WARNING] -> **`anonymous_store` MUST be a distinct store *instance* from `state_store` — not merely a different identifier passed to the same instance.** -> -> On the default `auth0-fastapi` cookie-backed stores (`StatelessStateStore`, `CookieTransactionStore`), the `identifier` argument to `set`/`get`/`delete` is used **only as an encryption salt** — the physical cookie name comes from the store instance's own `cookie_name`, fixed at construction. Two different identifiers written through the *same* store instance land on the *same* cookie and collide: the second write overwrites the first, and the failed decrypt on the next read is silently swallowed. Concretely, if you point `anonymous_store` at the same instance as `state_store`: -> -> - **Anonymous session created, then user logs in:** the login overwrites the anonymous session's cookie. The anonymous context is gone — the `sub`/metadata correlation this feature exists to deliver silently never happens. -> - **User logged in, then an anonymous session is created on the same request cycle:** the anonymous write overwrites the authenticated session's cookie. The next `get_session()` call decrypts garbage, returns `None`, and **the user is silently logged out** — no exception, no log line. -> -> Give `anonymous_store` its own `cookie_name` (or key prefix, or table) — a different construction, not a different string passed to the same one. If you omit `anonymous_store` entirely, every `.anonymous.*` call raises `ConfigurationError` immediately, before any write — it never falls back to `state_store`. - -This is not a hypothetical: it is the same root cause already live in this SDK's own `MfaClient`, which writes a second identifier (`_a0_mfa_pending`) into the shared state store today. If you are implementing a custom store, treat `identifier` as a value your store must resolve to a genuinely distinct record — not merely a distinct encryption salt on a fixed location. +Give `anonymous_store` its own store instance, not `state_store` with a different identifier. If you omit it, every `.anonymous.*` call raises `ConfigurationError` before any write. ## Creating a Session @@ -69,9 +56,9 @@ session = await server_client.anonymous.create_session( `AnonymousSession` never exposes the raw session token — only `sub`, `session_id`, `access_token`, `expires_at`, `session_expires_at`, `metadata`, and `is_new`. > [!IMPORTANT] -> **Always check `is_new`.** It is `True` both on the first call to `create_session()` and on a *silent* re-mint (see below) — the only signal your application receives when the anonymous `sub` has changed. Any code correlating data on `sub` (e.g. a cart keyed by anonymous user) must check this on every call, not just the first. +> **Always check `is_new`.** It is `True` both on the first call to `create_session()` and on a *silent* re-mint (see below) — the only signal your application receives when the anonymous `sub` has changed. Any code correlating data on `sub` (e.g. a cart keyed by anonymous user) must check this on every call. -## Getting a Token (Renewal Ladder) +## Getting a Token ```python token = await server_client.anonymous.get_token(store_options=store_options) @@ -90,8 +77,7 @@ Renewal logic, in order: status = await server_client.anonymous.introspect(store_options=store_options) ``` -> [!CAUTION] -> **This return shape is provisional.** The platform has not finalized what fields `/anonymous/userinfo` returns. The SDK's `AnonymousSessionIntrospection` model declares only `sub`, `session_id`, `expires_at`, and `metadata`, and ignores anything else in the response — a follow-up SDK release adding fields here is expected, not a breaking change. `introspect()` is a pure read: it never triggers the renewal ladder and never writes to the store on the SDK side. Whether the platform's own endpoint re-mints server-side as a side effect of being called is unconfirmed — treat that as a caveat if you observe it, not an SDK guarantee either way. +`introspect()` is read-only: it returns the current session status without renewing the token or changing `sub`. ## Logging Out @@ -100,21 +86,19 @@ await server_client.anonymous.logout(store_options=store_options) ``` > [!CAUTION] -> **`logout()` does not revoke.** There is no server-side anonymous session store to revoke against — this clears only the locally-held encrypted context. Any access token already issued for this anonymous session remains valid until its natural expiry. +> **`logout()` does not revoke.** There is no server-side anonymous session store to revoke against, this clears only the locally-held encrypted context. Any access token already issued for this anonymous session remains valid until its natural expiry. ## Login Injection -When an anonymous session is active, `start_interactive_login()` automatically includes the session token in the `/authorize` request — no code change needed at your call site. If no anonymous session exists, behavior is byte-identical to today. If the stored anonymous token is malformed or undecryptable, the link is silently dropped and the login proceeds normally — a broken anonymous session never blocks login. +When an anonymous session is active, `start_interactive_login()` automatically includes the session token in the `/authorize` request, no code change needed at your call site. If no anonymous session exists, behavior is same as today. -The session token is **only ever sourced from the SDK's own encrypted anonymous store** — there is no public API through which a caller can supply one directly, and any attempt to smuggle one in via `authorization_params` (constructor or per-call) is stripped before the request is built. This is deliberate: it closes a session-fixation vector where an attacker's anonymous session could otherwise be linked onto a victim's fresh login. - -The token travels as a query parameter to `/authorize`, which means it lands in browser history, `Referer` headers, and access logs. This is accepted because the token grants no authorization on its own and the request is a browser-to-Auth0 HTTPS redirect — but you should still set `Referrer-Policy: no-referrer` on your login pages, and never log the authorize URL. +The token travels as a query parameter to `/authorize`, which means it lands in browser history, `Referer` headers, and access logs. This is because the token grants no authorization on its own and the request is a browser-to-Auth0 HTTPS redirect, but you should still set `Referrer-Policy: no-referrer` on your login pages, and never log the authorize URL. Pushed Authorization Requests (PAR) are not supported for anonymous sessions — injection is suppressed entirely on that code path. ## Rate-Limiting `get_token()` -`get_token()`'s retry-once bound caps amplification to two upstream Auth0 calls *per invocation* — it does not protect against an attacker calling your route repeatedly. `POST /anonymous/token` is an unauthenticated, token-issuing endpoint. **You must rate-limit any route in your application that calls `get_token()` on an anonymous session**, the same way you would rate-limit any other unauthenticated token-issuing path. The SDK has no request-level context to do this itself. +`get_token()`'s retry-once bound caps amplification to two upstream Auth0 calls *per invocation*. It does not protect against an attacker calling your route repeatedly. `POST /anonymous/token` is an unauthenticated, token-issuing endpoint. **You must rate-limit any route in your application that calls `get_token()` on an anonymous session**, the same way you would rate-limit any other unauthenticated token-issuing path. The SDK has no request-level context to do this itself. ## Error Handling @@ -136,21 +120,11 @@ from auth0_server_python.error import ( try: session = await server_client.anonymous.create_session(audience="...", scope="...") except AnonymousFeatureNotEnabledError: - # tenant configuration problem — not a code bug ... ``` ## Known Limitations -- **Metadata is attacker-authored, pre-auth input.** By the time a Post-Login Action reads `event.anonymous_session.metadata`, it is untrusted data from an unauthenticated caller. The SDK validates size and rejects dangerous keys, but your Action author is responsible for validating content before trusting or persisting it. -- **Single audience per session.** `get_token()` takes no `audience` parameter — one anonymous session serves exactly one audience. To call two APIs anonymously, create two sessions (and accept that each has independent metadata and lifecycle). -- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API — this is a structural exclusion, not a runtime check. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions; you will see `AnonymousClientNotSupportedError`. +- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions; you will see `AnonymousClientNotSupportedError`. - **PAR, CIBA, Device Flow, RAR, and mTLS clients are not supported** for anonymous sessions. -- **Multiple Custom Domains (MCD):** the SDK gates on domain to prevent an anonymous session minted against one tenant being served on a resolver call for a different tenant — a mismatch silently mints a fresh session under the current tenant rather than serving cross-tenant state. -- **No server-side revocation.** See [Logging Out](#logging-out) above. - -## Additional Resources - -- [MFA.md](MFA.md) — anonymous sessions are unrelated to MFA and never interact with it. -- [ConfigureStore.md](ConfigureStore.md) — general store implementation guidance; anonymous sessions add the distinct-instance requirement above on top of everything there. -- [MultipleCustomDomains.md](MultipleCustomDomains.md) — background on the resolver-mode domain gating referenced above. +- **No server-side revocation.** See [Logging Out](#logging-out) above. \ No newline at end of file From c5d5cff11a9b3ff2806d86f3e67fde014579d762 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 14 Aug 2026 10:46:19 +0530 Subject: [PATCH 06/12] docs: Updated docs to remove semicolon --- examples/AnonymousSessions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 06408cd..e056fae 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -125,6 +125,6 @@ except AnonymousFeatureNotEnabledError: ## Known Limitations -- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions; you will see `AnonymousClientNotSupportedError`. +- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions, you will see `AnonymousClientNotSupportedError`. - **PAR, CIBA, Device Flow, RAR, and mTLS clients are not supported** for anonymous sessions. - **No server-side revocation.** See [Logging Out](#logging-out) above. \ No newline at end of file From cfb000a6a0025b71ce96b503f35f5084279ff3f7 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 19 Aug 2026 22:03:02 +0530 Subject: [PATCH 07/12] fix: rename Anonymous Session errors to add Session in the error names to match the spec --- examples/AnonymousSessions.md | 24 +++--- .../auth_server/anonymous_client.py | 86 +++++++++---------- src/auth0_server_python/error/__init__.py | 20 ++--- .../tests/test_anonymous_client.py | 48 +++++------ 4 files changed, 89 insertions(+), 89 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index e056fae..a3749ef 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -102,29 +102,29 @@ Pushed Authorization Requests (PAR) are not supported for anonymous sessions — ## Error Handling -All anonymous session errors subclass `AnonymousApiError`, carrying a `.code` you can branch on: +All anonymous session errors subclass `AnonymousSessionApiError`, carrying a `.code` you can branch on: ```python from auth0_server_python.error import ( - AnonymousFeatureNotEnabledError, - AnonymousClientNotEnabledError, - AnonymousClientNotSupportedError, - AnonymousResourceServerError, - AnonymousScopeError, - AnonymousCreateError, - AnonymousTokenError, - AnonymousIntrospectError, - AnonymousLogoutError, + AnonymousSessionFeatureNotEnabledError, + AnonymousSessionClientNotEnabledError, + AnonymousSessionClientNotSupportedError, + AnonymousSessionResourceServerError, + AnonymousSessionScopeError, + AnonymousSessionCreateError, + AnonymousSessionTokenError, + AnonymousSessionIntrospectError, + AnonymousSessionLogoutError, ) try: session = await server_client.anonymous.create_session(audience="...", scope="...") -except AnonymousFeatureNotEnabledError: +except AnonymousSessionFeatureNotEnabledError: ... ``` ## Known Limitations -- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions, you will see `AnonymousClientNotSupportedError`. +- **DPoP is not supported.** `AnonymousClient` has no `dpop_key` parameter anywhere in its public API. A tenant/client configured with `require_proof_of_possession: true` cannot use anonymous sessions, you will see `AnonymousSessionClientNotSupportedError`. - **PAR, CIBA, Device Flow, RAR, and mTLS clients are not supported** for anonymous sessions. - **No server-side revocation.** See [Logging Out](#logging-out) above. \ No newline at end of file diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 0f3d10d..cc7ec38 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -20,16 +20,16 @@ ) from auth0_server_python.encryption.encrypt import decrypt, encrypt from auth0_server_python.error import ( - AnonymousApiError, - AnonymousClientNotEnabledError, - AnonymousClientNotSupportedError, - AnonymousCreateError, - AnonymousFeatureNotEnabledError, - AnonymousIntrospectError, - AnonymousLogoutError, - AnonymousResourceServerError, - AnonymousScopeError, - AnonymousTokenError, + AnonymousSessionApiError, + AnonymousSessionClientNotEnabledError, + AnonymousSessionClientNotSupportedError, + AnonymousSessionCreateError, + AnonymousSessionFeatureNotEnabledError, + AnonymousSessionIntrospectError, + AnonymousSessionLogoutError, + AnonymousSessionResourceServerError, + AnonymousSessionScopeError, + AnonymousSessionTokenError, ConfigurationError, DomainResolverError, _AnonymousSessionExpired, @@ -200,25 +200,25 @@ def _map_anonymous_error( if code in ("session_expired", "invalid_session_token"): return _AnonymousSessionExpired(description) if status_code == 400 and "Proof-of-Possession" in description: - return AnonymousClientNotSupportedError(description, error_data) + return AnonymousSessionClientNotSupportedError(description, error_data) if code == "feature_not_enabled": - return AnonymousFeatureNotEnabledError(description, error_data) + return AnonymousSessionFeatureNotEnabledError(description, error_data) if code == "unauthorized_client": - return AnonymousClientNotEnabledError(description, error_data) + return AnonymousSessionClientNotEnabledError(description, error_data) if code in ("invalid_target", "invalid_request"): - return AnonymousResourceServerError(description, error_data) + return AnonymousSessionResourceServerError(description, error_data) if code == "invalid_scope": - return AnonymousScopeError(description, error_data) + return AnonymousSessionScopeError(description, error_data) if operation == "create": - return AnonymousCreateError(description, cause=error_data) + return AnonymousSessionCreateError(description, cause=error_data) if operation == "token": - return AnonymousTokenError(description, error_data) + return AnonymousSessionTokenError(description, error_data) if operation == "logout": - return AnonymousLogoutError(description, error_data) + return AnonymousSessionLogoutError(description, error_data) if operation == "introspect": - return AnonymousIntrospectError(description, error_data) - return AnonymousApiError(code or "anonymous_error", description, error_data) + return AnonymousSessionIntrospectError(description, error_data) + return AnonymousSessionApiError(code or "anonymous_error", description, error_data) # ============================================================================ # METADATA VALIDATION @@ -232,25 +232,25 @@ def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: metadata: The metadata dict to validate, or None. Raises: - AnonymousCreateError: metadata is not a dict, contains a + AnonymousSessionCreateError: metadata is not a dict, contains a disallowed key, a non-string value, or exceeds 1KB. """ if metadata is None: return if not isinstance(metadata, dict): - raise AnonymousCreateError("metadata must be a JSON object", code="invalid_metadata") + raise AnonymousSessionCreateError("metadata must be a JSON object", code="invalid_metadata") for key, value in metadata.items(): if key in _DANGEROUS_METADATA_KEYS: - raise AnonymousCreateError( + raise AnonymousSessionCreateError( f"metadata key '{key}' is not allowed", code="invalid_metadata" ) if not isinstance(value, str): - raise AnonymousCreateError( + raise AnonymousSessionCreateError( f"metadata value for key '{key}' must be a string", code="invalid_metadata" ) size = len(json.dumps(metadata).encode("utf-8")) if size > _METADATA_MAX_BYTES: - raise AnonymousCreateError( + raise AnonymousSessionCreateError( "metadata exceeds the 1KB size limit", code="metadata_too_large" ) @@ -319,7 +319,7 @@ async def _create_session_at( The newly created AnonymousSession, with is_new=True. Raises: - AnonymousCreateError: The request failed, or the response + AnonymousSessionCreateError: The request failed, or the response was invalid or missing required fields. """ base_url = f"https://{domain}" @@ -337,23 +337,23 @@ async def _create_session_at( try: response = await client.post(f"{base_url}/anonymous/token", json=body) except httpx.HTTPError as e: - raise AnonymousCreateError("Failed to reach the anonymous token endpoint") from e + raise AnonymousSessionCreateError("Failed to reach the anonymous token endpoint") from e if response.status_code != 200: error_data = self._parse_anonymous_error_body(response) mapped = self._map_anonymous_error(response.status_code, error_data, "create") if isinstance(mapped, _AnonymousSessionExpired): # Internal-only type must never escape. - raise AnonymousCreateError(str(mapped)) + raise AnonymousSessionCreateError(str(mapped)) raise mapped try: token_response = AnonymousTokenResponse.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: - raise AnonymousCreateError("Failed to parse anonymous token response") from e + raise AnonymousSessionCreateError("Failed to parse anonymous token response") from e if not token_response.session_token: - raise AnonymousCreateError("Anonymous token response missing required fields") + raise AnonymousSessionCreateError("Anonymous token response missing required fields") now = int(time.time()) context = AnonymousSessionContext( @@ -406,7 +406,7 @@ async def _remint( retry-once fallback created a brand-new session. Raises: - AnonymousTokenError: The request failed, or the response was + AnonymousSessionTokenError: The request failed, or the response was invalid. """ domain = context.domain or await self._resolve_domain(store_options) @@ -422,7 +422,7 @@ async def _remint( try: response = await client.post(f"{base_url}/anonymous/token", json=body) except httpx.HTTPError as e: - raise AnonymousTokenError("Failed to reach the anonymous token endpoint") from e + raise AnonymousSessionTokenError("Failed to reach the anonymous token endpoint") from e if response.status_code != 200: error_data = self._parse_anonymous_error_body(response) @@ -441,7 +441,7 @@ async def _remint( try: token_response = AnonymousTokenResponse.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: - raise AnonymousTokenError("Failed to parse anonymous token response") from e + raise AnonymousSessionTokenError("Failed to parse anonymous token response") from e now = int(time.time()) new_context = AnonymousSessionContext( @@ -538,7 +538,7 @@ async def create_session( Raises: ConfigurationError: No anonymous_store configured. - AnonymousCreateError: Invalid options, local validation + AnonymousSessionCreateError: Invalid options, local validation failure, or server rejection. """ self._require_store() @@ -547,7 +547,7 @@ async def create_session( try: options = CreateAnonymousSessionOptions(**options) except ValidationError as e: - raise AnonymousCreateError( + raise AnonymousSessionCreateError( "Invalid create_session options", code="invalid_options" ) from e audience = audience if audience is not None else options.audience @@ -572,13 +572,13 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano Raises: ConfigurationError: No anonymous_store configured. - AnonymousTokenError: No active session, or an unrecoverable + AnonymousSessionTokenError: No active session, or an unrecoverable failure. """ self._require_store() stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) if not stored: - raise AnonymousTokenError("No active anonymous session. Call create_session() first.") + raise AnonymousSessionTokenError("No active anonymous session. Call create_session() first.") try: context = self._decrypt_context(stored) @@ -634,18 +634,18 @@ async def introspect( Raises: ConfigurationError: No anonymous_store configured. - AnonymousIntrospectError: No active session, or a request + AnonymousSessionIntrospectError: No active session, or a request failure. """ self._require_store() stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) if not stored: - raise AnonymousIntrospectError("No active anonymous session to introspect.") + raise AnonymousSessionIntrospectError("No active anonymous session to introspect.") try: context = self._decrypt_context(stored) except _AnonymousSessionExpired as e: - raise AnonymousIntrospectError( + raise AnonymousSessionIntrospectError( "Stored anonymous session is invalid or corrupted." ) from e @@ -659,7 +659,7 @@ async def introspect( auth=BearerAuth(context.access_token), ) except httpx.HTTPError as e: - raise AnonymousIntrospectError( + raise AnonymousSessionIntrospectError( "Failed to reach the anonymous userinfo endpoint" ) from e @@ -667,13 +667,13 @@ async def introspect( error_data = self._parse_anonymous_error_body(response) mapped = self._map_anonymous_error(response.status_code, error_data, "introspect") if isinstance(mapped, _AnonymousSessionExpired): - raise AnonymousIntrospectError(str(mapped)) + raise AnonymousSessionIntrospectError(str(mapped)) raise mapped try: return AnonymousSessionIntrospection.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: - raise AnonymousIntrospectError( + raise AnonymousSessionIntrospectError( "Failed to parse anonymous introspection response" ) from e diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index fb43fa1..877c6cf 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -368,7 +368,7 @@ class PasskeyErrorCode: # Anonymous Session Error Classes # ============================================================================= -class AnonymousApiError(Auth0Error): +class AnonymousSessionApiError(Auth0Error): """Base class for anonymous session API errors.""" def __init__( @@ -382,63 +382,63 @@ def __init__( self.cause = cause -class AnonymousCreateError(AnonymousApiError): +class AnonymousSessionCreateError(AnonymousSessionApiError): """Error thrown when creating or re-minting an anonymous session fails.""" def __init__(self, message: str, code: str = "anonymous_create_error", cause: Optional[dict] = None): super().__init__(code, message, cause) -class AnonymousLogoutError(AnonymousApiError): +class AnonymousSessionLogoutError(AnonymousSessionApiError): """Error thrown when anonymous logout fails.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__("anonymous_logout_error", message, cause) -class AnonymousTokenError(AnonymousApiError): +class AnonymousSessionTokenError(AnonymousSessionApiError): """Error thrown when get_token() fails for reasons other than session expiry.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__("anonymous_token_error", message, cause) -class AnonymousIntrospectError(AnonymousApiError): +class AnonymousSessionIntrospectError(AnonymousSessionApiError): """Error thrown when introspect() fails on an HTTP or auth failure.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__("anonymous_introspect_error", message, cause) -class AnonymousFeatureNotEnabledError(AnonymousCreateError): +class AnonymousSessionFeatureNotEnabledError(AnonymousSessionCreateError): """Error thrown when the tenant has not enabled the anonymous sessions add-on.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_feature_not_enabled_error", cause) -class AnonymousClientNotEnabledError(AnonymousCreateError): +class AnonymousSessionClientNotEnabledError(AnonymousSessionCreateError): """Error thrown when the client is not enabled for anonymous sessions.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_client_not_enabled_error", cause) -class AnonymousClientNotSupportedError(AnonymousCreateError): +class AnonymousSessionClientNotSupportedError(AnonymousSessionCreateError): """Error thrown when the client type does not support anonymous sessions (e.g. DPoP-mandated).""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_client_not_supported_error", cause) -class AnonymousResourceServerError(AnonymousCreateError): +class AnonymousSessionResourceServerError(AnonymousSessionCreateError): """Error thrown when the requested audience is not a valid resource server.""" def __init__(self, message: str, cause: Optional[dict] = None): super().__init__(message, "anonymous_resource_server_error", cause) -class AnonymousScopeError(AnonymousCreateError): +class AnonymousSessionScopeError(AnonymousSessionCreateError): """Error thrown when the requested scope is not granted to anonymous callers.""" def __init__(self, message: str, cause: Optional[dict] = None): diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 4f667b6..c917785 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -19,14 +19,14 @@ ) from auth0_server_python.encryption.encrypt import encrypt from auth0_server_python.error import ( - AnonymousClientNotEnabledError, - AnonymousClientNotSupportedError, - AnonymousCreateError, - AnonymousFeatureNotEnabledError, - AnonymousIntrospectError, - AnonymousResourceServerError, - AnonymousScopeError, - AnonymousTokenError, + AnonymousSessionClientNotEnabledError, + AnonymousSessionClientNotSupportedError, + AnonymousSessionCreateError, + AnonymousSessionFeatureNotEnabledError, + AnonymousSessionIntrospectError, + AnonymousSessionResourceServerError, + AnonymousSessionScopeError, + AnonymousSessionTokenError, ConfigurationError, DomainResolverError, ) @@ -266,7 +266,7 @@ async def test_metadata_over_1kb_rejected_client_side_no_network_call(self): client = _make_client(anonymous_store=store) oversized = {"blob": "x" * 2000} with patch("httpx.AsyncClient") as mock_http: - with pytest.raises(AnonymousCreateError, match="1KB"): + with pytest.raises(AnonymousSessionCreateError, match="1KB"): await client.create_session(audience="aud", scope="s", metadata=oversized) mock_http.assert_not_called() @@ -274,14 +274,14 @@ async def test_metadata_over_1kb_rejected_client_side_no_network_call(self): async def test_dangerous_metadata_key_rejected(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousCreateError, match="not allowed"): + with pytest.raises(AnonymousSessionCreateError, match="not allowed"): await client.create_session(audience="aud", scope="s", metadata={"__proto__": "x"}) @pytest.mark.asyncio async def test_non_string_metadata_value_rejected(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousCreateError, match="must be a string"): + with pytest.raises(AnonymousSessionCreateError, match="must be a string"): await client.create_session(audience="aud", scope="s", metadata={"count": 5}) @pytest.mark.asyncio @@ -327,7 +327,7 @@ async def test_invalid_options_dict_raises_typed_error_no_network_call(self): store = OneSlotStore() client = _make_client(anonymous_store=store) with patch("httpx.AsyncClient") as mock_http: - with pytest.raises(AnonymousCreateError) as exc: + with pytest.raises(AnonymousSessionCreateError) as exc: await client.create_session(options={"unknown_field": "x"}) assert exc.value.code == "invalid_options" mock_http.assert_not_called() @@ -340,7 +340,7 @@ async def test_feature_not_enabled_maps_to_typed_error(self): _fake_response(403, {"error": "feature_not_enabled", "error_description": "disabled"}) ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousFeatureNotEnabledError): + with pytest.raises(AnonymousSessionFeatureNotEnabledError): await client.create_session(audience="aud", scope="s") @pytest.mark.asyncio @@ -351,7 +351,7 @@ async def test_unauthorized_client_maps_to_typed_error(self): _fake_response(403, {"error": "unauthorized_client", "error_description": "not enabled"}) ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousClientNotEnabledError): + with pytest.raises(AnonymousSessionClientNotEnabledError): await client.create_session(audience="aud", scope="s") @pytest.mark.asyncio @@ -363,7 +363,7 @@ async def test_dpop_required_client_maps_to_not_supported_with_literal_message(s _fake_response(400, {"error": "unauthorized_client", "error_description": message}) ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousClientNotSupportedError) as exc: + with pytest.raises(AnonymousSessionClientNotSupportedError) as exc: await client.create_session(audience="aud", scope="s") assert message in str(exc.value) @@ -375,7 +375,7 @@ async def test_invalid_target_maps_to_resource_server_error(self): _fake_response(400, {"error": "invalid_target", "error_description": "bad audience"}) ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousResourceServerError): + with pytest.raises(AnonymousSessionResourceServerError): await client.create_session(audience="aud", scope="s") @pytest.mark.asyncio @@ -386,7 +386,7 @@ async def test_invalid_scope_maps_to_scope_error(self): _fake_response(400, {"error": "invalid_scope", "error_description": "bad scope"}) ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousScopeError): + with pytest.raises(AnonymousSessionScopeError): await client.create_session(audience="aud", scope="s") @pytest.mark.asyncio @@ -408,7 +408,7 @@ async def post(self, *a, **k): raise httpx.ConnectError("boom") with patch("httpx.AsyncClient", _RaisingClient()): - with pytest.raises(AnonymousCreateError): + with pytest.raises(AnonymousSessionCreateError): await client.create_session(audience="aud", scope="s") @@ -430,7 +430,7 @@ async def test_fresh_cached_token_returned_with_no_http_call(self): async def test_no_active_session_raises_token_error(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousTokenError): + with pytest.raises(AnonymousSessionTokenError): await client.get_token() @pytest.mark.asyncio @@ -488,7 +488,7 @@ async def test_two_consecutive_session_expired_raises_not_loops(self): _fake_response(400, {"error": "session_expired", "error_description": "expired again"}), ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousCreateError): + with pytest.raises(AnonymousSessionCreateError): await client.get_token() assert len(fake_http.calls) == 2 @@ -501,7 +501,7 @@ async def test_other_error_code_raises_typed_error_no_retry(self): _fake_response(403, {"error": "feature_not_enabled", "error_description": "off"}), ]) with patch("httpx.AsyncClient", fake_http): - with pytest.raises(AnonymousFeatureNotEnabledError): + with pytest.raises(AnonymousSessionFeatureNotEnabledError): await client.get_token() assert len(fake_http.calls) == 1 @@ -537,7 +537,7 @@ async def post(self, *a, **k): raise httpx.ConnectError("network down") with patch("httpx.AsyncClient", _RaisingClient()): - with pytest.raises(AnonymousTokenError): + with pytest.raises(AnonymousSessionTokenError): await client.get_token() @pytest.mark.asyncio @@ -635,7 +635,7 @@ async def test_introspect_never_writes_to_store(self): async def test_introspect_no_active_session_raises(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousIntrospectError): + with pytest.raises(AnonymousSessionIntrospectError): await client.introspect() @@ -671,7 +671,7 @@ async def test_get_token_after_logout_behaves_as_no_session(self): fake_http = _FakeAsyncClient([_fake_response(200, {})]) with patch("httpx.AsyncClient", fake_http): await client.logout() - with pytest.raises(AnonymousTokenError): + with pytest.raises(AnonymousSessionTokenError): await client.get_token() @pytest.mark.asyncio From ee70a38fc116d6846ca32ae4150bc9d1f521072b Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 19 Aug 2026 22:40:22 +0530 Subject: [PATCH 08/12] fix: Resolved logout error correctly --- examples/AnonymousSessions.md | 2 ++ .../auth_server/anonymous_client.py | 29 +++++++++++++++++-- .../tests/test_anonymous_client.py | 29 ++++++++++++++++++- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index a3749ef..bc86a64 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -88,6 +88,8 @@ await server_client.anonymous.logout(store_options=store_options) > [!CAUTION] > **`logout()` does not revoke.** There is no server-side anonymous session store to revoke against, this clears only the locally-held encrypted context. Any access token already issued for this anonymous session remains valid until its natural expiry. +Local state is always cleared, even if the remote call fails. If the remote `/anonymous/logout` call itself fails, `logout()` raises `AnonymousSessionLogoutError` after clearing local state, so the failure isn't swallowed. + ## Login Injection When an anonymous session is active, `start_interactive_login()` automatically includes the session token in the `/authorize` request, no code change needed at your call site. If no anonymous session exists, behavior is same as today. diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index cc7ec38..866be27 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -680,8 +680,16 @@ async def introspect( async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: """Clear the locally-held anonymous session without revoking issued tokens. + Local state is cleared unconditionally, even when the remote call + fails, since there is no server-side session to keep in sync with. + Args: store_options: Options passed to the anonymous store. + + Raises: + ConfigurationError: No anonymous_store configured. + AnonymousSessionLogoutError: The remote logout call failed for a + reason other than the session already being expired/invalid. """ self._require_store() stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) @@ -693,6 +701,9 @@ async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: except _AnonymousSessionExpired: context = None + error_to_raise: Optional[Exception] = None + error_cause: Optional[BaseException] = None + if context is not None: domain = context.domain or await self._resolve_domain(store_options) base_url = f"https://{domain}" @@ -704,8 +715,20 @@ async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: body["client_secret"] = self._client_secret try: async with self._get_http_client() as client: - await client.post(f"{base_url}/anonymous/logout", json=body) - except httpx.HTTPError: - pass + response = await client.post(f"{base_url}/anonymous/logout", json=body) + except httpx.HTTPError as e: + error_to_raise = AnonymousSessionLogoutError( + "Failed to reach the anonymous logout endpoint" + ) + error_cause = e + else: + if response.status_code != 200: + error_data = self._parse_anonymous_error_body(response) + mapped = self._map_anonymous_error(response.status_code, error_data, "logout") + if not isinstance(mapped, _AnonymousSessionExpired): + error_to_raise = mapped await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) + + if error_to_raise is not None: + raise error_to_raise from error_cause diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index c917785..07bf12f 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -24,6 +24,7 @@ AnonymousSessionCreateError, AnonymousSessionFeatureNotEnabledError, AnonymousSessionIntrospectError, + AnonymousSessionLogoutError, AnonymousSessionResourceServerError, AnonymousSessionScopeError, AnonymousSessionTokenError, @@ -683,7 +684,7 @@ async def test_logout_with_no_session_is_a_noop(self): mock_http.assert_not_called() @pytest.mark.asyncio - async def test_logout_remote_call_failure_does_not_block_local_clear(self): + async def test_logout_remote_call_failure_still_clears_local_state_then_raises(self): store = OneSlotStore() _stored_context(store) client = _make_client(anonymous_store=store) @@ -702,6 +703,32 @@ async def post(self, *a, **k): raise httpx.ConnectError("boom") with patch("httpx.AsyncClient", _RaisingClient()): + with pytest.raises(AnonymousSessionLogoutError): + await client.logout() + assert store.slot is None + + @pytest.mark.asyncio + async def test_logout_non_200_response_still_clears_local_state_then_raises(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient( + [_fake_response(500, {"error": "server_error", "error_description": "boom"})] + ) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousSessionLogoutError): + await client.logout() + assert store.slot is None + + @pytest.mark.asyncio + async def test_logout_session_expired_response_is_not_raised(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient( + [_fake_response(400, {"error": "session_expired", "error_description": "expired"})] + ) + with patch("httpx.AsyncClient", fake_http): await client.logout() assert store.slot is None From 3b25bcfaaa3acb50ff304eaaf010f08e48f8ea90 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 19 Aug 2026 22:47:52 +0530 Subject: [PATCH 09/12] fix: make AnonymousClient domain typed --- src/auth0_server_python/auth_server/anonymous_client.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 866be27..6944fdf 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -5,7 +5,7 @@ import json import time -from typing import Any, Optional, Union +from typing import Any, Callable, Optional, Union import httpx from pydantic import ValidationError @@ -56,7 +56,7 @@ class AnonymousClient: def __init__( self, - domain, + domain: Union[str, Callable, None], client_id: str, client_secret: str, secret: str, From 307ba7df35b463cbd89490213cf5dc06d6480a02 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 19 Aug 2026 23:27:08 +0530 Subject: [PATCH 10/12] fix: widened metadata types --- examples/AnonymousSessions.md | 2 +- .../auth_server/anonymous_client.py | 15 ++++++++------- .../tests/test_anonymous_client.py | 18 +++++++++++++++--- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index bc86a64..077fc02 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -51,7 +51,7 @@ session = await server_client.anonymous.create_session( ) ``` -`metadata` is **set once, at creation, and never updated** — there is no platform update endpoint for anonymous sessions. Top-level string values only, ≤1 KB total (UTF-8 JSON byte length); oversized or non-string values are rejected client-side before any network call. +`metadata` is **set once, at creation, and never updated** — there is no platform update endpoint for anonymous sessions. Any JSON-serializable value is accepted, ≤1 KB total (UTF-8 JSON byte length); oversized, non-JSON-serializable, or dangerous-key (`__proto__`, `constructor`, `prototype`) metadata is rejected client-side before any network call. `AnonymousSession` never exposes the raw session token — only `sub`, `session_id`, `access_token`, `expires_at`, `session_expires_at`, `metadata`, and `is_new`. diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 6944fdf..17e0f32 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -233,22 +233,23 @@ def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: Raises: AnonymousSessionCreateError: metadata is not a dict, contains a - disallowed key, a non-string value, or exceeds 1KB. + disallowed key, a non-JSON-serializable value, or exceeds 1KB. """ if metadata is None: return if not isinstance(metadata, dict): raise AnonymousSessionCreateError("metadata must be a JSON object", code="invalid_metadata") - for key, value in metadata.items(): + for key in metadata: if key in _DANGEROUS_METADATA_KEYS: raise AnonymousSessionCreateError( f"metadata key '{key}' is not allowed", code="invalid_metadata" ) - if not isinstance(value, str): - raise AnonymousSessionCreateError( - f"metadata value for key '{key}' must be a string", code="invalid_metadata" - ) - size = len(json.dumps(metadata).encode("utf-8")) + try: + size = len(json.dumps(metadata).encode("utf-8")) + except TypeError as e: + raise AnonymousSessionCreateError( + "metadata must contain only JSON-serializable values", code="invalid_metadata" + ) from e if size > _METADATA_MAX_BYTES: raise AnonymousSessionCreateError( "metadata exceeds the 1KB size limit", code="metadata_too_large" diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 07bf12f..dedb1f7 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -279,11 +279,23 @@ async def test_dangerous_metadata_key_rejected(self): await client.create_session(audience="aud", scope="s", metadata={"__proto__": "x"}) @pytest.mark.asyncio - async def test_non_string_metadata_value_rejected(self): + async def test_non_string_metadata_value_accepted(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - with pytest.raises(AnonymousSessionCreateError, match="must be a string"): - await client.create_session(audience="aud", scope="s", metadata={"count": 5}) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) + with patch("httpx.AsyncClient", fake_http): + await client.create_session( + audience="aud", scope="s", metadata={"count": 5, "active": True, "tags": ["a", "b"]} + ) + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["metadata"] == {"count": 5, "active": True, "tags": ["a", "b"]} + + @pytest.mark.asyncio + async def test_non_json_serializable_metadata_value_rejected(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with pytest.raises(AnonymousSessionCreateError, match="JSON-serializable"): + await client.create_session(audience="aud", scope="s", metadata={"bad": object()}) @pytest.mark.asyncio async def test_create_session_accepts_options_model(self): From c3e6fd907952b45ebd93366ac944e6c9edf58978 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 19 Aug 2026 23:30:17 +0530 Subject: [PATCH 11/12] fix: session_token, sub, session_id in _remint now use explicit is not None checks instead of or --- .../auth_server/anonymous_client.py | 14 +++++++--- .../tests/test_anonymous_client.py | 27 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 17e0f32..b4bc377 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -446,9 +446,17 @@ async def _remint( now = int(time.time()) new_context = AnonymousSessionContext( - session_token=token_response.session_token or context.session_token, - sub=token_response.sub or context.sub, - session_id=token_response.session_id or context.session_id, + session_token=( + token_response.session_token + if token_response.session_token is not None + else context.session_token + ), + sub=token_response.sub if token_response.sub is not None else context.sub, + session_id=( + token_response.session_id + if token_response.session_id is not None + else context.session_id + ), access_token=token_response.access_token, expires_at=now + token_response.expires_in, session_expires_at=( diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index dedb1f7..c48e40d 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -463,6 +463,33 @@ async def test_expired_access_token_remints_via_session_token_grant(self): assert kwargs["json"]["session_token"] == "ST1" assert "refresh_token" not in kwargs["json"] + @pytest.mark.asyncio + async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_stale_context( + self, + ): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response( + 200, + { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_token": "", + "sub": "", + "session_id": "", + }, + ) + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.sub == "" + assert session.session_id == "" + token = await client.get_session_token_for_injection() + assert token == "" + @pytest.mark.asyncio async def test_expired_session_token_triggers_silent_new_session(self): store = OneSlotStore() From 6304af8c39f86af5086efe4597e6537af51fccba Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 20 Aug 2026 14:52:32 +0530 Subject: [PATCH 12/12] fix: seperate response structures for session token in create and remint --- .../auth_server/anonymous_client.py | 6 ++---- src/auth0_server_python/auth_types/__init__.py | 9 +++++++++ .../tests/test_anonymous_client.py | 11 +++++++++++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index b4bc377..5203ff7 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -12,6 +12,7 @@ from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( + AnonymousCreateTokenResponse, AnonymousSession, AnonymousSessionContext, AnonymousSessionIntrospection, @@ -349,13 +350,10 @@ async def _create_session_at( raise mapped try: - token_response = AnonymousTokenResponse.model_validate(response.json()) + token_response = AnonymousCreateTokenResponse.model_validate(response.json()) except (json.JSONDecodeError, ValueError, ValidationError) as e: raise AnonymousSessionCreateError("Failed to parse anonymous token response") from e - if not token_response.session_token: - raise AnonymousSessionCreateError("Anonymous token response missing required fields") - now = int(time.time()) context = AnonymousSessionContext( session_token=token_response.session_token, diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 3068a12..a910ee3 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -904,6 +904,15 @@ class AnonymousTokenResponse(BaseModel): session_id: Optional[str] = None +class AnonymousCreateTokenResponse(AnonymousTokenResponse): + """Raw response from POST /anonymous/token on the create path. + + session_token is always present on creation, unlike on re-mint. + """ + + session_token: str + + class AnonymousSessionContext(BaseModel): """Internal context stored inside the encrypted anonymous session record. diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index c48e40d..429c90c 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -225,6 +225,17 @@ async def test_create_session_success(self): assert session.is_new is True assert session.metadata == {"cart_id": "c1"} + @pytest.mark.asyncio + async def test_create_session_response_missing_session_token_raises(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + response_without_session_token = _token_response() + del response_without_session_token["session_token"] + fake_http = _FakeAsyncClient([_fake_response(200, response_without_session_token)]) + with patch("httpx.AsyncClient", fake_http): + with pytest.raises(AnonymousSessionCreateError): + await client.create_session(audience="aud", scope="s") + @pytest.mark.asyncio async def test_create_session_sends_client_secret_in_json_body_not_auth_tuple(self): store = OneSlotStore()