From 796c9ed6e30f6471cec6cf8c10b4fb670030c112 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 11 Aug 2026 11:21:04 +0530 Subject: [PATCH 01/49] 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 7e6c1657..e88fdc98 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 00000000..3f700d27 --- /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 611f6b7e..9bf85e8e 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 00000000..5b088d97 --- /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 c8eb6b39..47ecc16c 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 e886938a..f73c9ab6 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 ee9279ff..c6bae6e6 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 00000000..6a0f157e --- /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 c1c012a7..2c8bc780 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 e7d51ccc..192b24bb 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/49] 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 3f700d27..bd0c234b 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 5b088d97..7a8e91c2 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 47ecc16c..c7b365bc 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 f73c9ab6..8c0e7a74 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 c6bae6e6..bf2b4a1c 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 6a0f157e..ce3fc7bb 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 2c8bc780..0bdb1369 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 192b24bb..e7d51ccc 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/49] 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 bd0c234b..2e730544 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 7a8e91c2..2dea7508 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 8c0e7a74..3b126aaf 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 bf2b4a1c..03d69a75 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 ce3fc7bb..4f667b63 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/49] 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 2dea7508..0f3d10d2 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 c7b365bc..aac2207d 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 3b126aaf..3068a127 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 03d69a75..fb43fa17 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/49] 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 2e730544..06408cd9 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/49] 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 06408cd9..e056faea 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/49] 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 e056faea..a3749ef5 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 0f3d10d2..cc7ec381 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 fb43fa17..877c6cf0 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 4f667b63..c917785f 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/49] 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 a3749ef5..bc86a641 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 cc7ec381..866be27a 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 c917785f..07bf12f2 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/49] 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 866be27a..6944fdf4 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/49] 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 bc86a641..077fc020 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 6944fdf4..17e0f329 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 07bf12f2..dedb1f7b 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/49] 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 17e0f329..b4bc3778 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 dedb1f7b..c48e40d9 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/49] 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 b4bc3778..5203ff74 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 3068a127..a910ee3e 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 c48e40d9..429c90cd 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() From edeb3d199187266f81421f13969bb94cf9735326 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 20 Aug 2026 16:27:39 +0530 Subject: [PATCH 13/49] test: extract shared OneSlotStore fake into store_fakes Co-Authored-By: Claude Opus 4.8 --- src/auth0_server_python/tests/store_fakes.py | 26 ++++++++++ .../tests/test_anonymous_client.py | 25 +--------- .../tests/test_server_client.py | 50 ++++++------------- 3 files changed, 41 insertions(+), 60 deletions(-) create mode 100644 src/auth0_server_python/tests/store_fakes.py diff --git a/src/auth0_server_python/tests/store_fakes.py b/src/auth0_server_python/tests/store_fakes.py new file mode 100644 index 00000000..b06a7004 --- /dev/null +++ b/src/auth0_server_python/tests/store_fakes.py @@ -0,0 +1,26 @@ +"""Shared test doubles for the stateless store contract.""" + + +class OneSlotStore: + """Models a StatelessStateStore where the store identifier is an + encryption salt, not a location key. + + One physical slot per instance, so a mismatched identifier reads as + absent, not as a different record. AsyncMock cannot exercise this + collision because it treats every identifier as a distinct key, so 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 diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 429c90cd..d33fb425 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -31,6 +31,7 @@ ConfigurationError, DomainResolverError, ) +from auth0_server_python.tests.store_fakes import OneSlotStore # Shared fixtures DOMAIN = "auth0.local" @@ -39,30 +40,6 @@ 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, 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): - 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, diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 0bdb1369..10d5996e 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -65,6 +65,7 @@ SessionExpiredError, StartLinkUserError, ) +from auth0_server_python.tests.store_fakes import OneSlotStore from auth0_server_python.utils import PKCE, State @@ -8957,29 +8958,6 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker # ============================================================================= -class _OneSlotStore: - """ - 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): - 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", @@ -9012,7 +8990,7 @@ async def test_server_client_anonymous_property(): async def test_anonymous_client_receives_own_store_not_state_store(): """The anonymous client must never share the authenticated state store instance.""" state_store = AsyncMock() - anon_store = _OneSlotStore() + anon_store = OneSlotStore() client = ServerClient( domain="auth0.local", client_id="cid", @@ -9059,7 +9037,7 @@ def fake_create_url(endpoint, **kwargs): @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 = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) client = ServerClient( domain="auth0.local", @@ -9090,7 +9068,7 @@ def fake_create_url(endpoint, **kwargs): @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 = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) mock_transaction_store = AsyncMock() client = ServerClient( @@ -9121,7 +9099,7 @@ async def test_start_interactive_login_stamps_session_token_into_transaction_dat @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 + anon_store = OneSlotStore() # no session ever created client = ServerClient( domain="auth0.local", client_id="", @@ -9151,7 +9129,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.""" - anon_store = _OneSlotStore() + anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": "not-a-valid-jwe"}) client = ServerClient( domain="auth0.local", @@ -9181,7 +9159,7 @@ async def test_start_interactive_login_malformed_anonymous_token_denies_link_all async def test_start_interactive_login_suppresses_injection_on_par_branch(mocker): """PAR is not supported for anonymous sessions.""" secret = "a-test-secret-with-enough-length" - anon_store = _OneSlotStore() + anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) client = ServerClient( domain="auth0.local", @@ -9239,7 +9217,7 @@ async def test_start_interactive_login_constructor_fixation_blocked_no_active_se """ assert "session_token" in INTERNAL_AUTHORIZE_PARAMS # belt-and-braces still present - anon_store = _OneSlotStore() # no session -> the vulnerable case + anon_store = OneSlotStore() # no session -> the vulnerable case client = ServerClient( domain="auth0.local", client_id="", @@ -9272,7 +9250,7 @@ def fake_create_url(endpoint, **kwargs): @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() + anon_store = OneSlotStore() client = ServerClient( domain="auth0.local", client_id="", @@ -9304,7 +9282,7 @@ def fake_create_url(endpoint, **kwargs): @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 = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) client = ServerClient( domain="auth0.local", @@ -9346,10 +9324,10 @@ async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_st the authenticated session on a separate store instance is provably untouched. The separate-instance contract holds. """ - shared_store = _OneSlotStore() + shared_store = OneSlotStore() shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) - anon_store = _OneSlotStore() + anon_store = OneSlotStore() client = ServerClient( domain="auth0.local", client_id="cid", @@ -9376,7 +9354,7 @@ async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_st If an integrator forgets anonymous_store, the client must raise before any write, never silently write anonymous state into ServerClient's state_store. """ - shared_store = _OneSlotStore() + shared_store = OneSlotStore() shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) client = ServerClient( domain="auth0.local", @@ -9397,7 +9375,7 @@ async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_st 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 = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) mock_state_store = AsyncMock() mock_state_store.get = AsyncMock(return_value=None) From bc40a56ecb8816bc436d694d173e06becc5458ad Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 20 Aug 2026 16:27:51 +0530 Subject: [PATCH 14/49] fix: run anonymous session domain-mismatch check in static mode too Co-Authored-By: Claude Opus 4.8 --- .../auth_server/anonymous_client.py | 26 +++++++++---------- .../auth_types/__init__.py | 2 -- .../tests/test_anonymous_client.py | 13 ++++++++++ 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 5203ff74..a831dbec 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -599,20 +599,18 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano 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, - ) + current_domain = await self._resolve_domain(store_options) + if context.domain and self._normalize_url(context.domain) != self._normalize_url( + current_domain + ): + # Never reuse a session across domains: discard and mint fresh. + 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: diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index a910ee3e..9366c3db 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -927,8 +927,6 @@ class AnonymousSessionContext(BaseModel): session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None created_at: int - # 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/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index d33fb425..127c682e 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -599,6 +599,19 @@ async def test_domain_mismatch_in_resolver_mode_mints_fresh_under_current_tenant assert session.sub == "anon@fresh-b" assert session.is_new is True + @pytest.mark.asyncio + async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(self): + store = OneSlotStore() + _stored_context( + store, expires_at=int(time.time()) + 3600, domain="tenant-a.auth0.local" + ) + 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.sub == "anon@fresh" + assert session.is_new is True + @pytest.mark.asyncio async def test_domain_resolver_failure_propagates(self): resolver = AsyncMock(return_value=None) From 15e47325a27b19f4e6ee8508e9aac429ce72dfe4 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 20 Aug 2026 16:27:56 +0530 Subject: [PATCH 15/49] test: cover corrupted anonymous context in introspect and logout Co-Authored-By: Claude Opus 4.8 --- .../tests/test_anonymous_client.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 127c682e..d402f6ba 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -679,6 +679,16 @@ async def test_introspect_no_active_session_raises(self): with pytest.raises(AnonymousSessionIntrospectError): await client.introspect() + @pytest.mark.asyncio + async def test_introspect_corrupted_context_raises_without_server_call(self): + store = OneSlotStore() + store.slot = (ANON_IDENTIFIER, {"context": "not-a-decryptable-blob"}) + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + with pytest.raises(AnonymousSessionIntrospectError): + await client.introspect() + mock_http.assert_not_called() + # ── logout ──────────────────────────────────────────────────────────────────── @@ -772,6 +782,16 @@ async def test_logout_session_expired_response_is_not_raised(self): await client.logout() assert store.slot is None + @pytest.mark.asyncio + async def test_logout_corrupted_context_clears_state_without_server_call(self): + store = OneSlotStore() + store.slot = (ANON_IDENTIFIER, {"context": "not-a-decryptable-blob"}) + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.logout() + mock_http.assert_not_called() + assert store.slot is None + # ── get_session_token_for_injection (login-injection support) ─────────────── From 61d727ebd39c9ad8a022aa6d0efb6c1a25330cb2 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 21 Aug 2026 14:10:16 +0530 Subject: [PATCH 16/49] fix: drop sub,session_id from anonymous sessions response, fix logout auth and remint replay --- .gitignore | 3 +- .../auth_server/anonymous_client.py | 65 +++---- .../auth_types/__init__.py | 9 +- .../tests/test_anonymous_client.py | 181 +++++++++++++++--- .../tests/test_server_client.py | 4 +- 5 files changed, 189 insertions(+), 73 deletions(-) diff --git a/.gitignore b/.gitignore index 24939dcc..4a79d715 100644 --- a/.gitignore +++ b/.gitignore @@ -27,4 +27,5 @@ test-script.py coverage.xml # AI tools -.claude \ No newline at end of file +.claude +.worktrees diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index a831dbec..4fa11d67 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -350,22 +350,27 @@ async def _create_session_at( raise mapped try: - token_response = AnonymousCreateTokenResponse.model_validate(response.json()) - except (json.JSONDecodeError, ValueError, ValidationError) as e: + body = response.json() + except (json.JSONDecodeError, ValueError) as e: + raise AnonymousSessionCreateError("Failed to parse anonymous token response") from e + + if isinstance(body, dict) and not body.get("session_token"): + raise AnonymousSessionCreateError( + "Anonymous token response contained no session_token. Enable session_token in the response for this tenant.", + code="missing_session_token", + ) + + try: + token_response = AnonymousCreateTokenResponse.model_validate(body) + except (ValueError, ValidationError) as e: raise AnonymousSessionCreateError("Failed to parse anonymous token response") from e 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 - ), + session_expires_at=now + token_response.session_expires_in, metadata=metadata, created_at=now, domain=domain, @@ -378,9 +383,8 @@ async def _create_session_at( options=store_options, ) return AnonymousSession( - sub=context.sub, - session_id=context.session_id, access_token=context.access_token, + session_token=context.session_token, expires_at=context.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, @@ -416,6 +420,11 @@ async def _remint( } if self._client_secret: body["client_secret"] = self._client_secret + # Re-mint must replay audience/scope. The endpoint doesn't remember them. + if context.audience: + body["audience"] = context.audience + if context.scope: + body["scope"] = context.scope async with self._get_http_client() as client: try: @@ -449,19 +458,9 @@ async def _remint( 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=( - now + token_response.session_expires_in - if token_response.session_expires_in - else context.session_expires_at - ), + session_expires_at=now + token_response.session_expires_in, metadata=context.metadata, created_at=context.created_at, domain=domain, @@ -474,9 +473,8 @@ async def _remint( options=store_options, ) return AnonymousSession( - sub=new_context.sub, - session_id=new_context.session_id, access_token=new_context.access_token, + session_token=new_context.session_token, expires_at=new_context.expires_at, session_expires_at=new_context.session_expires_at, metadata=new_context.metadata, @@ -603,7 +601,6 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano if context.domain and self._normalize_url(context.domain) != self._normalize_url( current_domain ): - # Never reuse a session across domains: discard and mint fresh. return await self._create_session_at( current_domain, audience=context.audience, @@ -615,9 +612,8 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano now = int(time.time()) if context.expires_at > now: return AnonymousSession( - sub=context.sub, - session_id=context.session_id, access_token=context.access_token, + session_token=context.session_token, expires_at=context.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, @@ -712,22 +708,21 @@ async def logout(self, store_options: Optional[dict[str, Any]] = None) -> 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 + auth = ( + (self._client_id, self._client_secret) if self._client_secret else None + ) try: async with self._get_http_client() as client: - response = await client.post(f"{base_url}/anonymous/logout", json=body) + response = await client.post( + f"{base_url}/anonymous/logout", json={}, auth=auth + ) 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: + if not 200 <= response.status_code < 300: error_data = self._parse_anonymous_error_body(response) mapped = self._map_anonymous_error(response.status_code, error_data, "logout") if not isinstance(mapped, _AnonymousSessionExpired): diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 9366c3db..f7864fc2 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -873,9 +873,8 @@ class PasskeyTokenResponse(BaseModel): class AnonymousSession(BaseModel): """Public result of create_session() and the renewal ladder.""" - sub: Optional[str] = None - session_id: Optional[str] = None access_token: str + session_token: str expires_at: int session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None @@ -898,10 +897,8 @@ class AnonymousTokenResponse(BaseModel): access_token: str token_type: str = "Bearer" expires_in: int + session_expires_in: int session_token: Optional[str] = None - session_expires_in: Optional[int] = None - sub: Optional[str] = None - session_id: Optional[str] = None class AnonymousCreateTokenResponse(AnonymousTokenResponse): @@ -920,8 +917,6 @@ class AnonymousSessionContext(BaseModel): """ session_token: str - 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/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index d402f6ba..3ab0995f 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -88,8 +88,6 @@ def _token_response( expires_in=3600, session_token="ST1", # noqa: S107 session_expires_in=2592000, - sub="anon@abc", - session_id="sid1", ): return { "access_token": access_token, @@ -97,16 +95,12 @@ def _token_response( "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()), @@ -197,11 +191,50 @@ async def test_create_session_success(self): 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_exposes_session_token_to_caller(self): + """create_session() must expose session_token to the caller.""" + 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="aud", scope="s") + assert session.session_token == "ST1" + + @pytest.mark.asyncio + async def test_get_token_exposes_session_token_on_cached_and_reminted_paths(self): + """get_token() must carry session_token on both cached and re-minted paths.""" + store = OneSlotStore() + client = _make_client(anonymous_store=store) + + _stored_context(store) + cached = await client.get_token() + assert cached.session_token == "ST1" + + _stored_context(store, expires_at=int(time.time()) - 10) + fake_http = _FakeAsyncClient( + [_fake_response(200, _token_response(access_token="AT2", session_token="ST2"))] + ) + with patch("httpx.AsyncClient", fake_http): + reminted = await client.get_token() + assert reminted.session_token == "ST2" + + @pytest.mark.asyncio + async def test_remint_without_session_token_keeps_exposing_prior_token(self): + """Re-mint response omitting session_token must keep exposing the prior one.""" + store = OneSlotStore() + client = _make_client(anonymous_store=store) + _stored_context(store, expires_at=int(time.time()) - 10) + response = _token_response(access_token="AT2") + del response["session_token"] + fake_http = _FakeAsyncClient([_fake_response(200, response)]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.session_token == "ST1" + @pytest.mark.asyncio async def test_create_session_response_missing_session_token_raises(self): store = OneSlotStore() @@ -210,8 +243,10 @@ async def test_create_session_response_missing_session_token_raises(self): 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): + with pytest.raises(AnonymousSessionCreateError) as excinfo: await client.create_session(audience="aud", scope="s") + assert excinfo.value.code == "missing_session_token" + assert store.slot is None @pytest.mark.asyncio async def test_create_session_sends_client_secret_in_json_body_not_auth_tuple(self): @@ -245,8 +280,6 @@ 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, - # never touched by anonymous writes. assert state_store.slot == ("_a0_session", {"user": "authenticated"}) @pytest.mark.asyncio @@ -440,17 +473,75 @@ async def test_expired_access_token_remints_via_session_token_grant(self): _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}) + _fake_response( + 200, + { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }, + ) ]) 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_remint_replays_audience_and_scope_from_the_stored_session(self): + """Re-mint request must replay the session's stored audience and scope.""" + store = OneSlotStore() + _stored_context( + store, + expires_at=int(time.time()) - 10, + audience="https://api.example.com", + scope="read:things", + ) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response( + 200, + { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }, + ) + ]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, _, kwargs = fake_http.calls[0] + assert kwargs["json"]["audience"] == "https://api.example.com" + assert kwargs["json"]["scope"] == "read:things" + + @pytest.mark.asyncio + async def test_remint_omits_audience_and_scope_when_the_session_had_none(self): + """Absent values must stay absent - never sent as null or empty string.""" + 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_expires_in": 2592000, + }, + ) + ]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, _, kwargs = fake_http.calls[0] + assert "audience" not in kwargs["json"] + assert "scope" not in kwargs["json"] + @pytest.mark.asyncio async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_stale_context( self, @@ -465,16 +556,13 @@ async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_s "access_token": "AT2", "token_type": "Bearer", "expires_in": 3600, + "session_expires_in": 2592000, "session_token": "", - "sub": "", - "session_id": "", }, ) ]) with patch("httpx.AsyncClient", fake_http): - session = await client.get_token() - assert session.sub == "" - assert session.session_id == "" + await client.get_token() token = await client.get_session_token_for_injection() assert token == "" @@ -485,12 +573,11 @@ async def test_expired_session_token_triggers_silent_new_session(self): 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")), + _fake_response(200, _token_response()), ]) 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): @@ -538,11 +625,10 @@ 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"))]) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) 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): @@ -593,10 +679,9 @@ async def test_domain_mismatch_in_resolver_mode_mints_fresh_under_current_tenant 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"))]) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) 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 @@ -606,10 +691,9 @@ async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(s store, expires_at=int(time.time()) + 3600, domain="tenant-a.auth0.local" ) client = _make_client(anonymous_store=store) - fake_http = _FakeAsyncClient([_fake_response(200, _token_response(sub="anon@fresh"))]) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) with patch("httpx.AsyncClient", fake_http): session = await client.get_token() - assert session.sub == "anon@fresh" assert session.is_new is True @pytest.mark.asyncio @@ -658,7 +742,6 @@ async def test_introspect_missing_optional_field_does_not_raise(self): 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 @@ -703,6 +786,50 @@ async def test_logout_clears_anonymous_store(self): await client.logout() assert store.slot is None + @pytest.mark.asyncio + async def test_logout_sends_empty_body_and_authenticates_via_header(self): + """logout() must send an empty body and authenticate via the Authorization header.""" + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(204, {})]) + with patch("httpx.AsyncClient", fake_http): + await client.logout() + _, url, kwargs = fake_http.calls[0] + assert url.endswith("/anonymous/logout") + assert kwargs["json"] == {} + assert kwargs["auth"] == (CLIENT_ID, CLIENT_SECRET) + + @pytest.mark.asyncio + async def test_logout_treats_204_no_content_as_success(self): + """Auth0 answers 204 No Content on success.""" + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(204, {})]) + with patch("httpx.AsyncClient", fake_http): + await client.logout() # must not raise + assert store.slot is None + + @pytest.mark.asyncio + async def test_logout_without_client_secret_sends_no_client_auth(self): + """A public client has no secret to present - omit auth, don't send None fields.""" + store = OneSlotStore() + _stored_context(store) + client = AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + anonymous_store=store, + ) + fake_http = _FakeAsyncClient([_fake_response(204, {})]) + with patch("httpx.AsyncClient", fake_http): + await client.logout() + _, _, kwargs = fake_http.calls[0] + assert kwargs["auth"] is None + assert kwargs["json"] == {} + @pytest.mark.asyncio async def test_logout_does_not_touch_unrelated_authenticated_store(self): anon_store = OneSlotStore() diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 10d5996e..2b6c63d1 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9099,7 +9099,7 @@ async def test_start_interactive_login_stamps_session_token_into_transaction_dat @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 + anon_store = OneSlotStore() client = ServerClient( domain="auth0.local", client_id="", @@ -9341,7 +9341,6 @@ async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_st 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 @@ -9367,7 +9366,6 @@ async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_st ) 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"}}) From ead816f0dd2ca31b5a9e694fd87ec63e4c483b1a Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 24 Aug 2026 00:00:58 +0530 Subject: [PATCH 17/49] fix: remove is_new from AnonymousSession, unused signal never in spec --- .../auth_server/anonymous_client.py | 8 ++------ .../auth_types/__init__.py | 1 - .../tests/test_anonymous_client.py | 20 +++++++++---------- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 4fa11d67..eba00b4b 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -318,7 +318,7 @@ async def _create_session_at( store_options: Options passed to the anonymous store. Returns: - The newly created AnonymousSession, with is_new=True. + The newly created AnonymousSession. Raises: AnonymousSessionCreateError: The request failed, or the response @@ -388,7 +388,6 @@ async def _create_session_at( expires_at=context.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, - is_new=True, ) # ============================================================================ @@ -405,8 +404,7 @@ async def _remint( store_options: Options passed to the anonymous store. Returns: - The refreshed AnonymousSession. is_new is True only when the - retry-once fallback created a brand-new session. + The refreshed AnonymousSession. Raises: AnonymousSessionTokenError: The request failed, or the response was @@ -478,7 +476,6 @@ async def _remint( expires_at=new_context.expires_at, session_expires_at=new_context.session_expires_at, metadata=new_context.metadata, - is_new=False, ) # ============================================================================ @@ -617,7 +614,6 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano 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) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index f7864fc2..d778ed08 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -878,7 +878,6 @@ class AnonymousSession(BaseModel): expires_at: int session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None - is_new: bool class AnonymousSessionIntrospection(BaseModel): diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 3ab0995f..2975d336 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -191,7 +191,6 @@ async def test_create_session_success(self): session = await client.create_session( audience="https://api.example.com", scope="read:cart", metadata={"cart_id": "c1"} ) - assert session.is_new is True assert session.metadata == {"cart_id": "c1"} @pytest.mark.asyncio @@ -457,7 +456,6 @@ async def test_fresh_cached_token_returned_with_no_http_call(self): 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 @@ -486,7 +484,6 @@ async def test_expired_access_token_remints_via_session_token_grant(self): with patch("httpx.AsyncClient", fake_http): session = await client.get_token() assert session.access_token == "AT2" - assert session.is_new is False _, _, kwargs = fake_http.calls[0] assert kwargs["json"]["session_token"] == "ST1" assert "refresh_token" not in kwargs["json"] @@ -576,8 +573,8 @@ async def test_expired_session_token_triggers_silent_new_session(self): _fake_response(200, _token_response()), ]) with patch("httpx.AsyncClient", fake_http): - session = await client.get_token() - assert session.is_new is True + await client.get_token() + assert len(fake_http.calls) == 2 @pytest.mark.asyncio async def test_silent_remint_drops_metadata(self): @@ -628,7 +625,8 @@ async def test_corrupted_stored_token_triggers_silent_new_session(self): fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) with patch("httpx.AsyncClient", fake_http): session = await client.get_token() - assert session.is_new is True + assert session.access_token == "AT1" + assert len(fake_http.calls) == 1 @pytest.mark.asyncio async def test_network_error_during_renewal_not_misclassified_as_expiry(self): @@ -681,8 +679,9 @@ async def test_domain_mismatch_in_resolver_mode_mints_fresh_under_current_tenant client._domain = None fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) with patch("httpx.AsyncClient", fake_http): - session = await client.get_token() - assert session.is_new is True + await client.get_token() + _, url, _ = fake_http.calls[0] + assert url.startswith("https://tenant-b.auth0.local") @pytest.mark.asyncio async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(self): @@ -693,8 +692,9 @@ async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(s client = _make_client(anonymous_store=store) fake_http = _FakeAsyncClient([_fake_response(200, _token_response())]) with patch("httpx.AsyncClient", fake_http): - session = await client.get_token() - assert session.is_new is True + await client.get_token() + _, url, _ = fake_http.calls[0] + assert "tenant-a.auth0.local" not in url @pytest.mark.asyncio async def test_domain_resolver_failure_propagates(self): From 090b7988c5d402aa5114b29ff38660cf5d4f7a3c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 24 Aug 2026 00:02:40 +0530 Subject: [PATCH 18/49] docs: remove is_new from AnonymousSession field list --- examples/AnonymousSessions.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 077fc020..99b5d4b9 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -53,10 +53,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. 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`. - -> [!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. +`AnonymousSession` returns `session_token`, `access_token`, `expires_at`, `session_expires_at`, and `metadata`. ## Getting a Token @@ -68,7 +65,7 @@ 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. +3. Session token also expired or invalid → a **brand-new session is silently created, once**. Metadata from the old session is permanently lost, and `session_token` 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 From ea0575b850de0849c80e4e2ffed15c278da91821 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 23 Sep 2026 14:00:28 +0530 Subject: [PATCH 19/49] feat: carry anonymous session to login via short-lived transfer ticket Replace raw session_token query-param injection with a 30s anon_transfer_token minted from the stored session at /authorize build time and never persisted. Fails closed on an MCD domain mismatch, fails open on any exchange error (login proceeds without linking), and suppresses injection entirely on PAR and Enterprise Connect. Strip caller-supplied session_token/anon_transfer_token to close a fixation vector. Preserve metadata on the get_token domain-mismatch re-mint, and clear the local anonymous store on authenticated logout (local-only, no remote call) to close the shared-device re-injection path. Co-Authored-By: Claude Opus 4.8 --- examples/AnonymousSessions.md | 12 +- examples/MultipleCustomDomains.md | 4 + .../auth_server/anonymous_client.py | 95 ++++++- .../auth_server/server_client.py | 35 ++- .../auth_types/__init__.py | 14 +- .../tests/test_anonymous_client.py | 213 ++++++++++++++-- .../tests/test_server_client.py | 239 +++++++++++++++++- 7 files changed, 564 insertions(+), 48 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 99b5d4b9..b1eb5e99 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -1,6 +1,6 @@ # 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. +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 SDK carries the session to Auth0 as a short-lived transfer ticket 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. @@ -87,13 +87,17 @@ await server_client.anonymous.logout(store_options=store_options) 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. +Authenticated (OIDC) logout also ends an active anonymous session. When you call `ServerClient.logout()` and an anonymous store is configured, the SDK clears the locally-held anonymous session before returning the logout URL. This is a local clear only, with no remote call (consistent with `anonymous.logout()`, which also does not revoke server-side). It prevents the next visitor on a shared device from having the previous visitor's anonymous identity re-linked at their login. If no anonymous session is active, nothing is cleared, and any failure ending the anonymous session is best-effort and never breaks the authenticated logout. + ## 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. +When an anonymous session is active, `start_interactive_login()` automatically links it to the login, no code change needed at your call site. If no anonymous session exists, behavior is the same as today. + +The raw session token never goes on the URL. At the moment the `/authorize` URL is built, the SDK exchanges the stored session token for a short-lived (30s) transfer ticket (`anon_transfer_token`) via `POST /anonymous/token`, and forwards only that ticket as the `anon_transfer_token` query parameter. The raw session token stays inside the SDK's encrypted store and the ticket is never persisted. The ticket is short-lived and grants no authorization on its own, 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. +The exchange fails open: if it errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. Under Multiple Custom Domains it fails closed: a ticket is only minted for the domain the session was created against, so a domain mismatch mints nothing (see [MultipleCustomDomains.md](MultipleCustomDomains.md)). -Pushed Authorization Requests (PAR) are not supported for anonymous sessions — injection is suppressed entirely on that code path. +Pushed Authorization Requests (PAR) and Enterprise Connect are not supported for anonymous-session linking, so injection is suppressed entirely on those code paths. ## Rate-Limiting `get_token()` diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index ec6c68be..3fda1eec 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -323,6 +323,10 @@ All domain mismatch errors use the message: **"Session domain does not match the > **Note:** If a login was started before the switch to resolver mode and completes after, the SDK falls back to the current resolved domain for token exchange. The resulting session will store the resolved domain and work normally going forward. +### Anonymous-session linking + +Anonymous-session login linking is domain-bound and fails closed under MCD. When `start_interactive_login()` builds the `/authorize` URL, the SDK mints the anonymous transfer ticket (`anon_transfer_token`) only when the anonymous session's stored domain matches the resolved login domain. On a domain mismatch the SDK mints no ticket, so the anonymous session is never carried across custom domains. Linking then simply does not happen for that login; the login itself proceeds normally (fail-open on the linking, fail-closed on the domain). + ## Legacy Sessions and Migration When moving from a static domain setup to resolver mode, existing sessions can continue diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index eba00b4b..a07ee14f 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -17,6 +17,7 @@ AnonymousSessionContext, AnonymousSessionIntrospection, AnonymousTokenResponse, + AnonymousTransferTokenResponse, CreateAnonymousSessionOptions, ) from auth0_server_python.encryption.encrypt import decrypt, encrypt @@ -43,6 +44,10 @@ ANON_IDENTIFIER = "_a0_anon" ANON_TOKEN_SALT = "anon_session" +# Audience for the /anonymous/token exchange that mints a short-lived transfer +# ticket for login injection. +TRANSFER_AUDIENCE = "urn:auth0:anon_transfer" + _METADATA_MAX_BYTES = 1024 _DANGEROUS_METADATA_KEYS = frozenset({"__proto__", "constructor", "prototype"}) @@ -482,17 +487,24 @@ async def _remint( # LOGIN INJECTION SUPPORT # ============================================================================ - async def get_session_token_for_injection( - self, store_options: Optional[dict[str, Any]] = None + async def exchange_transfer_token_for_injection( + self, origin_domain: str, store_options: Optional[dict[str, Any]] = None ) -> Optional[str]: - """Read the active session token for login injection without renewing. + """Mint a short-lived transfer ticket from the active session for login injection. + + Reads the stored session without renewing it, then exchanges it for a + 30s ``anon_transfer_token``. Fails open (returns None) when there is no + store, no active session, the record cannot be decrypted, or the + exchange fails. Fails closed (returns None) on an MCD domain mismatch, + so a ticket is never minted for a host the session was not created + against. The ticket is never persisted. Args: + origin_domain: The domain the /authorize URL is being built for. store_options: Options passed to the anonymous store. Returns: - The raw session token, or None when there is no store, no active - session, or the stored record cannot be decrypted. + The minted transfer ticket, or None. """ if self._anonymous_store is None: return None @@ -506,7 +518,51 @@ async def get_session_token_for_injection( context = self._decrypt_context(stored) except _AnonymousSessionExpired: return None - return context.session_token + # MCD fail-closed: never mint a ticket for a host the session was not + # created against. + if context.domain and self._normalize_url(context.domain) != self._normalize_url( + origin_domain + ): + return None + return await self._mint_transfer_token(context.session_token, origin_domain) + + async def _mint_transfer_token( + self, session_token: str, origin_domain: str + ) -> Optional[str]: + """Exchange a session token for a transfer ticket. Fails open. + + Posts to /anonymous/token with the transfer audience and returns the + ``anon_transfer_token``, or None on any HTTP, non-200, or parse error, + matching the platform's fail-open redemption. Never persists the ticket. + + Args: + session_token: The stored anonymous session token. + origin_domain: The domain the /authorize URL is being built for. + + Returns: + The minted transfer ticket, or None. + """ + base_url = f"https://{origin_domain}" + body: dict[str, Any] = { + "client_id": self._client_id, + "session_token": session_token, + "audience": TRANSFER_AUDIENCE, + } + if self._client_secret: + body["client_secret"] = self._client_secret + + try: + async with self._get_http_client() as client: + response = await client.post(f"{base_url}/anonymous/token", json=body) + except httpx.HTTPError: + return None + if response.status_code != 200: + return None + try: + token_response = AnonymousTransferTokenResponse.model_validate(response.json()) + except (json.JSONDecodeError, ValueError, ValidationError): + return None + return token_response.anon_transfer_token # ============================================================================ # PUBLIC API @@ -602,7 +658,7 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano current_domain, audience=context.audience, scope=context.scope, - metadata=None, + metadata=context.metadata, store_options=store_options, ) @@ -728,3 +784,28 @@ async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: if error_to_raise is not None: raise error_to_raise from error_cause + + async def end_session_if_active( + self, store_options: Optional[dict[str, Any]] = None + ) -> None: + """Clear the local anonymous session on authenticated logout, if one is active. + + Reads the anonymous store fail-soft and, when a session is present, + deletes only the locally-held encrypted context. Makes no remote call: + there is no server-side anonymous session store to revoke against, and + the local delete is what closes the shared-device re-injection path + (without it the next login through this store would re-inject the + previous visitor's anonymous identity). + + Args: + store_options: Options passed to the anonymous store. + """ + if self._anonymous_store is None: + return + try: + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + except Exception: + return + if not stored: + return + 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 8f96ae5a..0108eeaf 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -101,7 +101,7 @@ # dynamically from the resolved domain at login time. INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", "code_challenge", "code_challenge_method", "state", "nonce", "scope", - "session_token"] + "session_token", "anon_transfer_token"] # issued_token_type URN for a Session Transfer Token (STT). SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfer_token" @@ -837,17 +837,20 @@ async def start_interactive_login( if options.invitation: auth_params["invitation"] = options.invitation - # 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. + # The anonymous transfer ticket is minted late from the SDK's own + # encrypted anonymous store, never a caller. The pops strip any value + # seeded from the constructor defaults, closing a session-fixation + # vector. PAR and Enterprise Connect suppress injection entirely. The + # ticket rides the query string but is short-lived (30s) and single-use + # in effect; the raw session_token is never placed on the URL. 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 + auth_params.pop("anon_transfer_token", None) + if not self._pushed_authorization_requests and not self._enterprise_connect: + anon_transfer_token = await self._anonymous_client.exchange_transfer_token_for_injection( + origin_domain, store_options ) - if anonymous_session_token: - auth_params["session_token"] = anonymous_session_token + if anon_transfer_token: + auth_params["anon_transfer_token"] = anon_transfer_token # Build the transaction data to store with domain transaction_data = TransactionData( @@ -857,7 +860,6 @@ 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 @@ -1449,6 +1451,17 @@ async def logout( if session_domain and self._normalize_url(session_domain) == self._normalize_url(domain): await self._state_store.delete(self._state_identifier, store_options) + # End any active anonymous session on authenticated logout, closing the + # shared-device re-injection path. Best-effort: anonymous cleanup must + # never break the authenticated logout, so any failure is swallowed. + if self._anonymous_store is not None: + try: + await self._anonymous_client.end_session_if_active(store_options) + except Exception as e: + # Best-effort: anonymous cleanup must never break the + # authenticated logout. The anon errors carry no token. + logger.debug("Anonymous session cleanup on logout failed: %s", e) + # Return logout URL for the current resolved domain logout_url = URL.create_logout_url( domain, self._client_id, options.return_to, federated=bool(options.federated)) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 59bcc12b..61366c47 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -151,7 +151,6 @@ 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 @@ -925,6 +924,19 @@ class AnonymousCreateTokenResponse(AnonymousTokenResponse): session_token: str +class AnonymousTransferTokenResponse(BaseModel): + """Raw response from POST /anonymous/token on the transfer-ticket path. + + The exchange returns a short-lived ticket (token_type "N_A") carried on + the /authorize URL, not a bearer token. Lenient to unrecognized fields. + """ + + model_config = ConfigDict(extra="ignore") + anon_transfer_token: str + token_type: Optional[str] = None + expires_in: Optional[int] = None + + 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 2975d336..db4991e6 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -173,11 +173,6 @@ async def test_no_write_attempted_when_store_missing(self): 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 ──────────────────────────────────────────────────────────── @@ -560,8 +555,9 @@ async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_s ]) with patch("httpx.AsyncClient", fake_http): await client.get_token() - token = await client.get_session_token_for_injection() - assert token == "" + stored = await store.get(ANON_IDENTIFIER) + context = client._decrypt_context(stored) + assert context.session_token == "" @pytest.mark.asyncio async def test_expired_session_token_triggers_silent_new_session(self): @@ -696,6 +692,27 @@ async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(s _, url, _ = fake_http.calls[0] assert "tenant-a.auth0.local" not in url + @pytest.mark.asyncio + async def test_domain_mismatch_remint_preserves_metadata(self): + """A domain-mismatch re-mint must carry the stored metadata, not drop the cart.""" + store = OneSlotStore() + _stored_context( + store, + expires_at=int(time.time()) + 3600, + domain="tenant-a.auth0.local", + metadata={"cart": ["sku-1"]}, + ) + 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())]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, url, kwargs = fake_http.calls[0] + assert url.startswith("https://tenant-b.auth0.local") + assert kwargs["json"]["metadata"] == {"cart": ["sku-1"]} + @pytest.mark.asyncio async def test_domain_resolver_failure_propagates(self): resolver = AsyncMock(return_value=None) @@ -920,34 +937,190 @@ async def test_logout_corrupted_context_clears_state_without_server_call(self): assert store.slot is None -# ── get_session_token_for_injection (login-injection support) ─────────────── +# ── exchange_transfer_token_for_injection (transfer ticket) ───────────────────── -class TestGetSessionTokenForInjection: +_TRANSFER_OK = {"token_type": "N_A", "anon_transfer_token": "TICKET", "expires_in": 30} + + +class TestExchangeTransferTokenForInjection: @pytest.mark.asyncio - async def test_returns_token_when_active_session_exists(self): + async def test_success_returns_ticket_with_correct_request_body(self): store = OneSlotStore() - _stored_context(store, session_token="REAL_TOKEN") + _stored_context(store, session_token="REAL_TOKEN", domain="auth0.local") client = _make_client(anonymous_store=store) - token = await client.get_session_token_for_injection() - assert token == "REAL_TOKEN" + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + ticket = await client.exchange_transfer_token_for_injection("auth0.local") + assert ticket == "TICKET" + method, url, kwargs = fake_http.calls[0] + assert method == "POST" + assert url == "https://auth0.local/anonymous/token" + body = kwargs["json"] + assert body["audience"] == "urn:auth0:anon_transfer" + assert body["session_token"] == "REAL_TOKEN" + assert body["client_id"] == CLIENT_ID + assert body["client_secret"] == CLIENT_SECRET + + @pytest.mark.asyncio + async def test_missing_context_domain_mints_against_origin(self): + """A stored context with no domain is not an MCD mismatch; mint against origin.""" + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN") # domain defaults to None + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + ticket = await client.exchange_transfer_token_for_injection("auth0.local") + assert ticket == "TICKET" + _, url, _ = fake_http.calls[0] + assert url.startswith("https://auth0.local") + + @pytest.mark.asyncio + async def test_returns_none_without_store(self): + client = _make_client(anonymous_store=None) + assert await client.exchange_transfer_token_for_injection("auth0.local") is None @pytest.mark.asyncio - async def test_returns_none_when_no_session(self): + async def test_returns_none_when_no_session_no_http(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - assert await client.get_session_token_for_injection() is None + with patch("httpx.AsyncClient") as mock_http: + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + mock_http.assert_not_called() @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.""" + async def test_returns_none_on_corrupted_context(self): store = OneSlotStore() store.slot = (ANON_IDENTIFIER, {"context": "garbage"}) client = _make_client(anonymous_store=store) - assert await client.get_session_token_for_injection() is None + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_domain_mismatch_fails_closed_no_mint(self): + """MCD fail-closed: never mint a ticket for a host the session was not created against.""" + store = OneSlotStore() + _stored_context(store, domain="tenant-a.auth0.local") + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + result = await client.exchange_transfer_token_for_injection("tenant-b.auth0.local") + assert result is None + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_returns_none_on_non_200(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(400, {"error": "invalid_request"})]) + with patch("httpx.AsyncClient", fake_http): + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_returns_none_on_network_error(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + + class _Boom: + 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", _Boom()): + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_returns_none_on_invalid_response_shape(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {"token_type": "N_A", "expires_in": 30})]) + with patch("httpx.AsyncClient", fake_http): + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_never_persists_ticket(self): + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN", domain="auth0.local") + client = _make_client(anonymous_store=store) + before = store.slot + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + await client.exchange_transfer_token_for_injection("auth0.local") + assert store.slot is before # the ticket was never written to the store + + @pytest.mark.asyncio + async def test_returns_none_on_store_exception(self): + store = AsyncMock() + store.get = AsyncMock(side_effect=RuntimeError("store unavailable")) + client = _make_client(anonymous_store=store) + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_forwards_configured_headers(self): + """The exchange goes through _get_http_client, so telemetry/config headers are attached.""" + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + captured = {} + + class _Cap: + def __call__(self, *a, **k): + captured.update(k.get("headers", {})) + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + return _fake_response(200, _TRANSFER_OK) + + client = _make_client(anonymous_store=store, headers={"Auth0-Client": "abc"}) + with patch("httpx.AsyncClient", _Cap()): + await client.exchange_transfer_token_for_injection("auth0.local") + assert captured.get("Auth0-Client") == "abc" + + +# ── end_session_if_active (end anon session on authenticated logout) ──────────── + +class TestEndSessionIfActive: + @pytest.mark.asyncio + async def test_no_store_is_noop(self): + client = _make_client(anonymous_store=None) + with patch("httpx.AsyncClient") as mock_http: + await client.end_session_if_active() + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_no_session_makes_no_remote_call(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.end_session_if_active() + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_active_session_clears_local_without_remote_call(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.end_session_if_active() + mock_http.assert_not_called() + assert store.slot is None @pytest.mark.asyncio - async def test_returns_none_on_store_exception_never_raises(self): + async def test_store_exception_is_swallowed(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 + await client.end_session_if_active() # must not raise diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index b63a02df..7fe231c6 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9989,7 +9989,8 @@ def fake_create_url(endpoint, **kwargs): @pytest.mark.asyncio -async def test_start_interactive_login_injects_active_anonymous_session(mocker): +async def test_start_interactive_login_injects_transfer_ticket_not_session_token(mocker): + """The /authorize URL carries the minted anon_transfer_token, never the raw session_token.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10008,6 +10009,11 @@ async def test_start_interactive_login_injects_active_anonymous_session(mocker): "_get_oidc_metadata_cached", return_value={"authorization_endpoint": "https://auth0.local/authorize"}, ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) captured = {} def fake_create_url(endpoint, **kwargs): @@ -10016,11 +10022,13 @@ def fake_create_url(endpoint, **kwargs): 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" + assert captured.get("anon_transfer_token") == "TICKET_ABC" + assert "session_token" not in captured @pytest.mark.asyncio -async def test_start_interactive_login_stamps_session_token_into_transaction_data(mocker): +async def test_start_interactive_login_does_not_persist_transfer_token_in_transaction_data(mocker): + """The short-lived ticket rides the URL only; it is never written to the transaction record.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10040,6 +10048,11 @@ async def test_start_interactive_login_stamps_session_token_into_transaction_dat "_get_oidc_metadata_cached", return_value={"authorization_endpoint": "https://auth0.local/authorize"}, ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) mocker.patch.object( client._oauth, "create_authorization_url", @@ -10047,7 +10060,8 @@ async def test_start_interactive_login_stamps_session_token_into_transaction_dat ) await client.start_interactive_login() stored_tx = mock_transaction_store.set.call_args.args[1] - assert stored_tx.session_token == "ANON_TOKEN_1" + assert not hasattr(stored_tx, "session_token") + assert not hasattr(stored_tx, "anon_transfer_token") @pytest.mark.asyncio @@ -10134,6 +10148,11 @@ async def test_start_interactive_login_suppresses_injection_on_par_branch(mocker "pushed_authorization_request_endpoint": "https://auth0.local/oauth/par", }, ) + exchange = mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) captured = {} class _FakePost: @@ -10158,6 +10177,8 @@ async def post(self, url, **kwargs): mocker.patch("httpx.AsyncClient", _FakeHttpClient) await client.start_interactive_login() + exchange.assert_not_awaited() + assert "anon_transfer_token" not in captured assert "session_token" not in captured @@ -10253,6 +10274,11 @@ async def test_start_interactive_login_does_not_clobber_organization_or_invitati "_get_oidc_metadata_cached", return_value={"authorization_endpoint": "https://auth0.local/authorize"}, ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) captured = {} def fake_create_url(endpoint, **kwargs): @@ -10265,7 +10291,210 @@ def fake_create_url(endpoint, **kwargs): ) assert captured.get("organization") == "org_abc123" assert captured.get("invitation") == "inv_xyz" - assert captured.get("session_token") == "ANON_TOKEN_1" + assert captured.get("anon_transfer_token") == "TICKET_ABC" + + +@pytest.mark.asyncio +async def test_start_interactive_login_suppresses_injection_on_enterprise_connect(mocker): + """Enterprise Connect does not support anonymous-session linking: no exchange, no param.""" + 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, + enterprise_connect=True, + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + exchange = mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) + 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() + exchange.assert_not_awaited() + assert "anon_transfer_token" not in captured + assert "session_token" not in captured + + +@pytest.mark.asyncio +async def test_start_interactive_login_fail_open_when_exchange_returns_none(mocker): + """A failed/absent exchange yields no param and still returns the login URL (fail-open).""" + 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"}, + ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value=None), + ) + 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) + url = await client.start_interactive_login() + assert "anon_transfer_token" not in captured + assert url == "https://auth0.local/authorize?client_id=" + + +# ── end anonymous session on authenticated logout ─────────────────────────────── + + +class _CapturingHttpClient: + """Fake httpx.AsyncClient that records POST URLs and returns a fixed status.""" + + posted: list = [] + status = 204 + + 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): + type(self).posted.append(url) + + class _Resp: + status_code = _CapturingHttpClient.status + + def json(self): + return {} + + return _Resp() + + +@pytest.mark.asyncio +async def test_logout_ends_active_anonymous_session(mocker): + """Authenticated logout clears the local anonymous session with no remote call.""" + 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="cid", + client_secret="csecret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + url = await client.logout() + + assert not any(u.endswith("/anonymous/logout") for u in _CapturingHttpClient.posted) + assert anon_store.slot is None + assert "logout" in url + + +@pytest.mark.asyncio +async def test_logout_no_anonymous_session_makes_no_remote_call(mocker): + """With no active anonymous session, authenticated logout makes no anonymous remote call.""" + anon_store = OneSlotStore() # empty + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + await client.logout() + + assert _CapturingHttpClient.posted == [] + + +@pytest.mark.asyncio +async def test_logout_unchanged_without_anonymous_store(mocker): + """No anonymous_store configured: logout is unchanged and makes no anonymous remote call.""" + mock_state_store = AsyncMock() + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + state_store=mock_state_store, + secret="a-test-secret-with-enough-length", + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + url = await client.logout() + + mock_state_store.delete.assert_awaited_once() + assert _CapturingHttpClient.posted == [] + assert "logout" in url + + +@pytest.mark.asyncio +async def test_logout_survives_anonymous_session_cleanup_failure(mocker): + """A failure clearing the anonymous session must not break the authenticated logout.""" + anon_store = AsyncMock() + anon_store.get = AsyncMock(return_value={"context": "encrypted"}) + anon_store.delete = AsyncMock(side_effect=RuntimeError("store delete failed")) + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + url = await client.logout() + + assert "logout" in url + anon_store.delete.assert_awaited_once() # ── Store-collision regression ───────────────────────────────────────────────── From fa0b4299f569bc96f78f83f0f56d3cce3abdf535 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Wed, 23 Sep 2026 14:00:28 +0530 Subject: [PATCH 20/49] feat: carry anonymous session to login via short-lived transfer ticket Replace raw session_token query-param injection with a 30s anon_transfer_token minted from the stored session at /authorize build time and never persisted. Fails closed on an MCD domain mismatch, fails open on any exchange error (login proceeds without linking), and suppresses injection entirely on PAR and Enterprise Connect. Strip caller-supplied session_token/anon_transfer_token to close a fixation vector. Preserve metadata on the get_token domain-mismatch re-mint, and clear the local anonymous store on authenticated logout (local-only, no remote call) to close the shared-device re-injection path. Co-Authored-By: Claude Opus 4.8 --- examples/AnonymousSessions.md | 12 +- examples/MultipleCustomDomains.md | 4 + .../auth_server/anonymous_client.py | 95 ++++++- .../auth_server/server_client.py | 35 ++- .../auth_types/__init__.py | 14 +- .../tests/test_anonymous_client.py | 213 ++++++++++++++-- .../tests/test_server_client.py | 239 +++++++++++++++++- 7 files changed, 564 insertions(+), 48 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 99b5d4b9..b1eb5e99 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -1,6 +1,6 @@ # 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. +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 SDK carries the session to Auth0 as a short-lived transfer ticket 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. @@ -87,13 +87,17 @@ await server_client.anonymous.logout(store_options=store_options) 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. +Authenticated (OIDC) logout also ends an active anonymous session. When you call `ServerClient.logout()` and an anonymous store is configured, the SDK clears the locally-held anonymous session before returning the logout URL. This is a local clear only, with no remote call (consistent with `anonymous.logout()`, which also does not revoke server-side). It prevents the next visitor on a shared device from having the previous visitor's anonymous identity re-linked at their login. If no anonymous session is active, nothing is cleared, and any failure ending the anonymous session is best-effort and never breaks the authenticated logout. + ## 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. +When an anonymous session is active, `start_interactive_login()` automatically links it to the login, no code change needed at your call site. If no anonymous session exists, behavior is the same as today. + +The raw session token never goes on the URL. At the moment the `/authorize` URL is built, the SDK exchanges the stored session token for a short-lived (30s) transfer ticket (`anon_transfer_token`) via `POST /anonymous/token`, and forwards only that ticket as the `anon_transfer_token` query parameter. The raw session token stays inside the SDK's encrypted store and the ticket is never persisted. The ticket is short-lived and grants no authorization on its own, 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. +The exchange fails open: if it errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. Under Multiple Custom Domains it fails closed: a ticket is only minted for the domain the session was created against, so a domain mismatch mints nothing (see [MultipleCustomDomains.md](MultipleCustomDomains.md)). -Pushed Authorization Requests (PAR) are not supported for anonymous sessions — injection is suppressed entirely on that code path. +Pushed Authorization Requests (PAR) and Enterprise Connect are not supported for anonymous-session linking, so injection is suppressed entirely on those code paths. ## Rate-Limiting `get_token()` diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index ec6c68be..3fda1eec 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -323,6 +323,10 @@ All domain mismatch errors use the message: **"Session domain does not match the > **Note:** If a login was started before the switch to resolver mode and completes after, the SDK falls back to the current resolved domain for token exchange. The resulting session will store the resolved domain and work normally going forward. +### Anonymous-session linking + +Anonymous-session login linking is domain-bound and fails closed under MCD. When `start_interactive_login()` builds the `/authorize` URL, the SDK mints the anonymous transfer ticket (`anon_transfer_token`) only when the anonymous session's stored domain matches the resolved login domain. On a domain mismatch the SDK mints no ticket, so the anonymous session is never carried across custom domains. Linking then simply does not happen for that login; the login itself proceeds normally (fail-open on the linking, fail-closed on the domain). + ## Legacy Sessions and Migration When moving from a static domain setup to resolver mode, existing sessions can continue diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index eba00b4b..a07ee14f 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -17,6 +17,7 @@ AnonymousSessionContext, AnonymousSessionIntrospection, AnonymousTokenResponse, + AnonymousTransferTokenResponse, CreateAnonymousSessionOptions, ) from auth0_server_python.encryption.encrypt import decrypt, encrypt @@ -43,6 +44,10 @@ ANON_IDENTIFIER = "_a0_anon" ANON_TOKEN_SALT = "anon_session" +# Audience for the /anonymous/token exchange that mints a short-lived transfer +# ticket for login injection. +TRANSFER_AUDIENCE = "urn:auth0:anon_transfer" + _METADATA_MAX_BYTES = 1024 _DANGEROUS_METADATA_KEYS = frozenset({"__proto__", "constructor", "prototype"}) @@ -482,17 +487,24 @@ async def _remint( # LOGIN INJECTION SUPPORT # ============================================================================ - async def get_session_token_for_injection( - self, store_options: Optional[dict[str, Any]] = None + async def exchange_transfer_token_for_injection( + self, origin_domain: str, store_options: Optional[dict[str, Any]] = None ) -> Optional[str]: - """Read the active session token for login injection without renewing. + """Mint a short-lived transfer ticket from the active session for login injection. + + Reads the stored session without renewing it, then exchanges it for a + 30s ``anon_transfer_token``. Fails open (returns None) when there is no + store, no active session, the record cannot be decrypted, or the + exchange fails. Fails closed (returns None) on an MCD domain mismatch, + so a ticket is never minted for a host the session was not created + against. The ticket is never persisted. Args: + origin_domain: The domain the /authorize URL is being built for. store_options: Options passed to the anonymous store. Returns: - The raw session token, or None when there is no store, no active - session, or the stored record cannot be decrypted. + The minted transfer ticket, or None. """ if self._anonymous_store is None: return None @@ -506,7 +518,51 @@ async def get_session_token_for_injection( context = self._decrypt_context(stored) except _AnonymousSessionExpired: return None - return context.session_token + # MCD fail-closed: never mint a ticket for a host the session was not + # created against. + if context.domain and self._normalize_url(context.domain) != self._normalize_url( + origin_domain + ): + return None + return await self._mint_transfer_token(context.session_token, origin_domain) + + async def _mint_transfer_token( + self, session_token: str, origin_domain: str + ) -> Optional[str]: + """Exchange a session token for a transfer ticket. Fails open. + + Posts to /anonymous/token with the transfer audience and returns the + ``anon_transfer_token``, or None on any HTTP, non-200, or parse error, + matching the platform's fail-open redemption. Never persists the ticket. + + Args: + session_token: The stored anonymous session token. + origin_domain: The domain the /authorize URL is being built for. + + Returns: + The minted transfer ticket, or None. + """ + base_url = f"https://{origin_domain}" + body: dict[str, Any] = { + "client_id": self._client_id, + "session_token": session_token, + "audience": TRANSFER_AUDIENCE, + } + if self._client_secret: + body["client_secret"] = self._client_secret + + try: + async with self._get_http_client() as client: + response = await client.post(f"{base_url}/anonymous/token", json=body) + except httpx.HTTPError: + return None + if response.status_code != 200: + return None + try: + token_response = AnonymousTransferTokenResponse.model_validate(response.json()) + except (json.JSONDecodeError, ValueError, ValidationError): + return None + return token_response.anon_transfer_token # ============================================================================ # PUBLIC API @@ -602,7 +658,7 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano current_domain, audience=context.audience, scope=context.scope, - metadata=None, + metadata=context.metadata, store_options=store_options, ) @@ -728,3 +784,28 @@ async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: if error_to_raise is not None: raise error_to_raise from error_cause + + async def end_session_if_active( + self, store_options: Optional[dict[str, Any]] = None + ) -> None: + """Clear the local anonymous session on authenticated logout, if one is active. + + Reads the anonymous store fail-soft and, when a session is present, + deletes only the locally-held encrypted context. Makes no remote call: + there is no server-side anonymous session store to revoke against, and + the local delete is what closes the shared-device re-injection path + (without it the next login through this store would re-inject the + previous visitor's anonymous identity). + + Args: + store_options: Options passed to the anonymous store. + """ + if self._anonymous_store is None: + return + try: + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + except Exception: + return + if not stored: + return + 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 8f96ae5a..0108eeaf 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -101,7 +101,7 @@ # dynamically from the resolved domain at login time. INTERNAL_AUTHORIZE_PARAMS = ["client_id", "response_type", "code_challenge", "code_challenge_method", "state", "nonce", "scope", - "session_token"] + "session_token", "anon_transfer_token"] # issued_token_type URN for a Session Transfer Token (STT). SESSION_TRANSFER_TOKEN_TYPE = "urn:auth0:params:oauth:token-type:session_transfer_token" @@ -837,17 +837,20 @@ async def start_interactive_login( if options.invitation: auth_params["invitation"] = options.invitation - # 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. + # The anonymous transfer ticket is minted late from the SDK's own + # encrypted anonymous store, never a caller. The pops strip any value + # seeded from the constructor defaults, closing a session-fixation + # vector. PAR and Enterprise Connect suppress injection entirely. The + # ticket rides the query string but is short-lived (30s) and single-use + # in effect; the raw session_token is never placed on the URL. 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 + auth_params.pop("anon_transfer_token", None) + if not self._pushed_authorization_requests and not self._enterprise_connect: + anon_transfer_token = await self._anonymous_client.exchange_transfer_token_for_injection( + origin_domain, store_options ) - if anonymous_session_token: - auth_params["session_token"] = anonymous_session_token + if anon_transfer_token: + auth_params["anon_transfer_token"] = anon_transfer_token # Build the transaction data to store with domain transaction_data = TransactionData( @@ -857,7 +860,6 @@ 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 @@ -1449,6 +1451,17 @@ async def logout( if session_domain and self._normalize_url(session_domain) == self._normalize_url(domain): await self._state_store.delete(self._state_identifier, store_options) + # End any active anonymous session on authenticated logout, closing the + # shared-device re-injection path. Best-effort: anonymous cleanup must + # never break the authenticated logout, so any failure is swallowed. + if self._anonymous_store is not None: + try: + await self._anonymous_client.end_session_if_active(store_options) + except Exception as e: + # Best-effort: anonymous cleanup must never break the + # authenticated logout. The anon errors carry no token. + logger.debug("Anonymous session cleanup on logout failed: %s", e) + # Return logout URL for the current resolved domain logout_url = URL.create_logout_url( domain, self._client_id, options.return_to, federated=bool(options.federated)) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 59bcc12b..61366c47 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -151,7 +151,6 @@ 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 @@ -925,6 +924,19 @@ class AnonymousCreateTokenResponse(AnonymousTokenResponse): session_token: str +class AnonymousTransferTokenResponse(BaseModel): + """Raw response from POST /anonymous/token on the transfer-ticket path. + + The exchange returns a short-lived ticket (token_type "N_A") carried on + the /authorize URL, not a bearer token. Lenient to unrecognized fields. + """ + + model_config = ConfigDict(extra="ignore") + anon_transfer_token: str + token_type: Optional[str] = None + expires_in: Optional[int] = None + + 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 2975d336..db4991e6 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -173,11 +173,6 @@ async def test_no_write_attempted_when_store_missing(self): 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 ──────────────────────────────────────────────────────────── @@ -560,8 +555,9 @@ async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_s ]) with patch("httpx.AsyncClient", fake_http): await client.get_token() - token = await client.get_session_token_for_injection() - assert token == "" + stored = await store.get(ANON_IDENTIFIER) + context = client._decrypt_context(stored) + assert context.session_token == "" @pytest.mark.asyncio async def test_expired_session_token_triggers_silent_new_session(self): @@ -696,6 +692,27 @@ async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(s _, url, _ = fake_http.calls[0] assert "tenant-a.auth0.local" not in url + @pytest.mark.asyncio + async def test_domain_mismatch_remint_preserves_metadata(self): + """A domain-mismatch re-mint must carry the stored metadata, not drop the cart.""" + store = OneSlotStore() + _stored_context( + store, + expires_at=int(time.time()) + 3600, + domain="tenant-a.auth0.local", + metadata={"cart": ["sku-1"]}, + ) + 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())]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, url, kwargs = fake_http.calls[0] + assert url.startswith("https://tenant-b.auth0.local") + assert kwargs["json"]["metadata"] == {"cart": ["sku-1"]} + @pytest.mark.asyncio async def test_domain_resolver_failure_propagates(self): resolver = AsyncMock(return_value=None) @@ -920,34 +937,190 @@ async def test_logout_corrupted_context_clears_state_without_server_call(self): assert store.slot is None -# ── get_session_token_for_injection (login-injection support) ─────────────── +# ── exchange_transfer_token_for_injection (transfer ticket) ───────────────────── -class TestGetSessionTokenForInjection: +_TRANSFER_OK = {"token_type": "N_A", "anon_transfer_token": "TICKET", "expires_in": 30} + + +class TestExchangeTransferTokenForInjection: @pytest.mark.asyncio - async def test_returns_token_when_active_session_exists(self): + async def test_success_returns_ticket_with_correct_request_body(self): store = OneSlotStore() - _stored_context(store, session_token="REAL_TOKEN") + _stored_context(store, session_token="REAL_TOKEN", domain="auth0.local") client = _make_client(anonymous_store=store) - token = await client.get_session_token_for_injection() - assert token == "REAL_TOKEN" + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + ticket = await client.exchange_transfer_token_for_injection("auth0.local") + assert ticket == "TICKET" + method, url, kwargs = fake_http.calls[0] + assert method == "POST" + assert url == "https://auth0.local/anonymous/token" + body = kwargs["json"] + assert body["audience"] == "urn:auth0:anon_transfer" + assert body["session_token"] == "REAL_TOKEN" + assert body["client_id"] == CLIENT_ID + assert body["client_secret"] == CLIENT_SECRET + + @pytest.mark.asyncio + async def test_missing_context_domain_mints_against_origin(self): + """A stored context with no domain is not an MCD mismatch; mint against origin.""" + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN") # domain defaults to None + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + ticket = await client.exchange_transfer_token_for_injection("auth0.local") + assert ticket == "TICKET" + _, url, _ = fake_http.calls[0] + assert url.startswith("https://auth0.local") + + @pytest.mark.asyncio + async def test_returns_none_without_store(self): + client = _make_client(anonymous_store=None) + assert await client.exchange_transfer_token_for_injection("auth0.local") is None @pytest.mark.asyncio - async def test_returns_none_when_no_session(self): + async def test_returns_none_when_no_session_no_http(self): store = OneSlotStore() client = _make_client(anonymous_store=store) - assert await client.get_session_token_for_injection() is None + with patch("httpx.AsyncClient") as mock_http: + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + mock_http.assert_not_called() @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.""" + async def test_returns_none_on_corrupted_context(self): store = OneSlotStore() store.slot = (ANON_IDENTIFIER, {"context": "garbage"}) client = _make_client(anonymous_store=store) - assert await client.get_session_token_for_injection() is None + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_domain_mismatch_fails_closed_no_mint(self): + """MCD fail-closed: never mint a ticket for a host the session was not created against.""" + store = OneSlotStore() + _stored_context(store, domain="tenant-a.auth0.local") + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + result = await client.exchange_transfer_token_for_injection("tenant-b.auth0.local") + assert result is None + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_returns_none_on_non_200(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(400, {"error": "invalid_request"})]) + with patch("httpx.AsyncClient", fake_http): + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_returns_none_on_network_error(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + + class _Boom: + 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", _Boom()): + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_returns_none_on_invalid_response_shape(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, {"token_type": "N_A", "expires_in": 30})]) + with patch("httpx.AsyncClient", fake_http): + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_never_persists_ticket(self): + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN", domain="auth0.local") + client = _make_client(anonymous_store=store) + before = store.slot + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + await client.exchange_transfer_token_for_injection("auth0.local") + assert store.slot is before # the ticket was never written to the store + + @pytest.mark.asyncio + async def test_returns_none_on_store_exception(self): + store = AsyncMock() + store.get = AsyncMock(side_effect=RuntimeError("store unavailable")) + client = _make_client(anonymous_store=store) + assert await client.exchange_transfer_token_for_injection("auth0.local") is None + + @pytest.mark.asyncio + async def test_forwards_configured_headers(self): + """The exchange goes through _get_http_client, so telemetry/config headers are attached.""" + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + captured = {} + + class _Cap: + def __call__(self, *a, **k): + captured.update(k.get("headers", {})) + return self + + async def __aenter__(self): + return self + + async def __aexit__(self, *a): + return False + + async def post(self, *a, **k): + return _fake_response(200, _TRANSFER_OK) + + client = _make_client(anonymous_store=store, headers={"Auth0-Client": "abc"}) + with patch("httpx.AsyncClient", _Cap()): + await client.exchange_transfer_token_for_injection("auth0.local") + assert captured.get("Auth0-Client") == "abc" + + +# ── end_session_if_active (end anon session on authenticated logout) ──────────── + +class TestEndSessionIfActive: + @pytest.mark.asyncio + async def test_no_store_is_noop(self): + client = _make_client(anonymous_store=None) + with patch("httpx.AsyncClient") as mock_http: + await client.end_session_if_active() + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_no_session_makes_no_remote_call(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.end_session_if_active() + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_active_session_clears_local_without_remote_call(self): + store = OneSlotStore() + _stored_context(store, domain="auth0.local") + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.end_session_if_active() + mock_http.assert_not_called() + assert store.slot is None @pytest.mark.asyncio - async def test_returns_none_on_store_exception_never_raises(self): + async def test_store_exception_is_swallowed(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 + await client.end_session_if_active() # must not raise diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index b63a02df..7fe231c6 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -9989,7 +9989,8 @@ def fake_create_url(endpoint, **kwargs): @pytest.mark.asyncio -async def test_start_interactive_login_injects_active_anonymous_session(mocker): +async def test_start_interactive_login_injects_transfer_ticket_not_session_token(mocker): + """The /authorize URL carries the minted anon_transfer_token, never the raw session_token.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10008,6 +10009,11 @@ async def test_start_interactive_login_injects_active_anonymous_session(mocker): "_get_oidc_metadata_cached", return_value={"authorization_endpoint": "https://auth0.local/authorize"}, ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) captured = {} def fake_create_url(endpoint, **kwargs): @@ -10016,11 +10022,13 @@ def fake_create_url(endpoint, **kwargs): 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" + assert captured.get("anon_transfer_token") == "TICKET_ABC" + assert "session_token" not in captured @pytest.mark.asyncio -async def test_start_interactive_login_stamps_session_token_into_transaction_data(mocker): +async def test_start_interactive_login_does_not_persist_transfer_token_in_transaction_data(mocker): + """The short-lived ticket rides the URL only; it is never written to the transaction record.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10040,6 +10048,11 @@ async def test_start_interactive_login_stamps_session_token_into_transaction_dat "_get_oidc_metadata_cached", return_value={"authorization_endpoint": "https://auth0.local/authorize"}, ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) mocker.patch.object( client._oauth, "create_authorization_url", @@ -10047,7 +10060,8 @@ async def test_start_interactive_login_stamps_session_token_into_transaction_dat ) await client.start_interactive_login() stored_tx = mock_transaction_store.set.call_args.args[1] - assert stored_tx.session_token == "ANON_TOKEN_1" + assert not hasattr(stored_tx, "session_token") + assert not hasattr(stored_tx, "anon_transfer_token") @pytest.mark.asyncio @@ -10134,6 +10148,11 @@ async def test_start_interactive_login_suppresses_injection_on_par_branch(mocker "pushed_authorization_request_endpoint": "https://auth0.local/oauth/par", }, ) + exchange = mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) captured = {} class _FakePost: @@ -10158,6 +10177,8 @@ async def post(self, url, **kwargs): mocker.patch("httpx.AsyncClient", _FakeHttpClient) await client.start_interactive_login() + exchange.assert_not_awaited() + assert "anon_transfer_token" not in captured assert "session_token" not in captured @@ -10253,6 +10274,11 @@ async def test_start_interactive_login_does_not_clobber_organization_or_invitati "_get_oidc_metadata_cached", return_value={"authorization_endpoint": "https://auth0.local/authorize"}, ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) captured = {} def fake_create_url(endpoint, **kwargs): @@ -10265,7 +10291,210 @@ def fake_create_url(endpoint, **kwargs): ) assert captured.get("organization") == "org_abc123" assert captured.get("invitation") == "inv_xyz" - assert captured.get("session_token") == "ANON_TOKEN_1" + assert captured.get("anon_transfer_token") == "TICKET_ABC" + + +@pytest.mark.asyncio +async def test_start_interactive_login_suppresses_injection_on_enterprise_connect(mocker): + """Enterprise Connect does not support anonymous-session linking: no exchange, no param.""" + 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, + enterprise_connect=True, + authorization_params={"redirect_uri": "/test_redirect_uri"}, + ) + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"authorization_endpoint": "https://auth0.local/authorize"}, + ) + exchange = mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value="TICKET_ABC"), + ) + 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() + exchange.assert_not_awaited() + assert "anon_transfer_token" not in captured + assert "session_token" not in captured + + +@pytest.mark.asyncio +async def test_start_interactive_login_fail_open_when_exchange_returns_none(mocker): + """A failed/absent exchange yields no param and still returns the login URL (fail-open).""" + 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"}, + ) + mocker.patch.object( + client._anonymous_client, + "exchange_transfer_token_for_injection", + AsyncMock(return_value=None), + ) + 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) + url = await client.start_interactive_login() + assert "anon_transfer_token" not in captured + assert url == "https://auth0.local/authorize?client_id=" + + +# ── end anonymous session on authenticated logout ─────────────────────────────── + + +class _CapturingHttpClient: + """Fake httpx.AsyncClient that records POST URLs and returns a fixed status.""" + + posted: list = [] + status = 204 + + 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): + type(self).posted.append(url) + + class _Resp: + status_code = _CapturingHttpClient.status + + def json(self): + return {} + + return _Resp() + + +@pytest.mark.asyncio +async def test_logout_ends_active_anonymous_session(mocker): + """Authenticated logout clears the local anonymous session with no remote call.""" + 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="cid", + client_secret="csecret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + url = await client.logout() + + assert not any(u.endswith("/anonymous/logout") for u in _CapturingHttpClient.posted) + assert anon_store.slot is None + assert "logout" in url + + +@pytest.mark.asyncio +async def test_logout_no_anonymous_session_makes_no_remote_call(mocker): + """With no active anonymous session, authenticated logout makes no anonymous remote call.""" + anon_store = OneSlotStore() # empty + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + await client.logout() + + assert _CapturingHttpClient.posted == [] + + +@pytest.mark.asyncio +async def test_logout_unchanged_without_anonymous_store(mocker): + """No anonymous_store configured: logout is unchanged and makes no anonymous remote call.""" + mock_state_store = AsyncMock() + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + state_store=mock_state_store, + secret="a-test-secret-with-enough-length", + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + url = await client.logout() + + mock_state_store.delete.assert_awaited_once() + assert _CapturingHttpClient.posted == [] + assert "logout" in url + + +@pytest.mark.asyncio +async def test_logout_survives_anonymous_session_cleanup_failure(mocker): + """A failure clearing the anonymous session must not break the authenticated logout.""" + anon_store = AsyncMock() + anon_store.get = AsyncMock(return_value={"context": "encrypted"}) + anon_store.delete = AsyncMock(side_effect=RuntimeError("store delete failed")) + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + state_store=AsyncMock(), + transaction_store=AsyncMock(), + anonymous_store=anon_store, + secret="a-test-secret-with-enough-length", + ) + _CapturingHttpClient.posted = [] + _CapturingHttpClient.status = 204 + mocker.patch("httpx.AsyncClient", _CapturingHttpClient) + + url = await client.logout() + + assert "logout" in url + anon_store.delete.assert_awaited_once() # ── Store-collision regression ───────────────────────────────────────────────── From 8a3274f7b1c0384bab29ab376114b900d16bf234 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 00:08:47 +0530 Subject: [PATCH 21/49] feat: harden anonymous sessions with sub, get_session, and review fixes --- references/flow-map.md | 1 + .../auth_server/anonymous_client.py | 327 +++++++----- .../auth_server/server_client.py | 46 +- .../auth_types/__init__.py | 45 +- src/auth0_server_python/error/__init__.py | 6 +- .../tests/test_anonymous_client.py | 503 +++++++++++++----- .../tests/test_server_client.py | 126 ++++- 7 files changed, 764 insertions(+), 290 deletions(-) diff --git a/references/flow-map.md b/references/flow-map.md index 18f43dc6..0d6adfac 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -19,6 +19,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl | MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` | | Enterprise Connect | `start_enterprise_login`, `complete_interactive_login` (EC branch), `is_federated_domain` (standalone), `logout` (`federated`) | `auth_types/` (`StartEnterpriseLoginOptions`, `LogoutOptions.federated`), `error/` (`EnterpriseConnectError`); the SDK owns no session in this mode | `examples/EnterpriseConnect.md` | | mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify`, `token_endpoint_resolver`) | `examples/MutualTLS.md` | +| Anonymous sessions | `ServerClient.anonymous` (property, returns `AnonymousClient`): `create_session`, `get_token`, `get_session`, `logout`, `introspect`; `start_interactive_login` injects `session_token` via `AnonymousClient.get_session_token_for_injection` | `auth_server/anonymous_client.py`, `encryption/encrypt.py` (context encrypted at rest), `store/abstract.py` (requires a dedicated `anonymous_store` instance; shares the `StateStore` ABC) | `examples/AnonymousSessions.md` | Two rules cut across every flow above, so check them on any change here: resolve the domain through `await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index a07ee14f..47795527 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -3,6 +3,7 @@ Handles pre-login anon@ identity operations against the Auth0 anonymous session API. """ +import base64 import json import time from typing import Any, Callable, Optional, Union @@ -15,8 +16,10 @@ AnonymousCreateTokenResponse, AnonymousSession, AnonymousSessionContext, + AnonymousSessionData, AnonymousSessionIntrospection, AnonymousTokenResponse, + AnonymousTokenSetEntry, AnonymousTransferTokenResponse, CreateAnonymousSessionOptions, ) @@ -28,7 +31,6 @@ AnonymousSessionCreateError, AnonymousSessionFeatureNotEnabledError, AnonymousSessionIntrospectError, - AnonymousSessionLogoutError, AnonymousSessionResourceServerError, AnonymousSessionScopeError, AnonymousSessionTokenError, @@ -53,12 +55,7 @@ class AnonymousClient: - """ - Client for Auth0 anonymous session operations. - - Requires its own store instance, distinct from ServerClient's state_store. - DPoP is not supported with Anonymous Sessions. - """ + """Client for Auth0 anonymous session operations.""" def __init__( self, @@ -159,6 +156,51 @@ def _normalize_url(value: Optional[str]) -> Optional[str]: value = f"https://{value}" return value.rstrip("/") + # ============================================================================ + # TOKEN SET CACHE HELPERS + # ============================================================================ + + @staticmethod + def _decode_sub(access_token: str) -> Optional[str]: + """Extract the sub claim from a JWT access token without verifying the signature. + + Returns None for opaque tokens (JWE has 5 dot-separated parts), non-JWT + strings, or tokens that carry no sub claim. + """ + try: + parts = access_token.split(".") + if len(parts) != 3: + return None + padded = parts[1] + "=" * (4 - len(parts[1]) % 4) + payload = json.loads(base64.urlsafe_b64decode(padded)) + sub = payload.get("sub") + return str(sub) if sub else None + except Exception: + return None + + @staticmethod + def _find_token_set( + token_sets: list, + audience: Optional[str], + scope: Optional[str], + ) -> Optional[AnonymousTokenSetEntry]: + for ts in token_sets: + if ts.audience == audience and ts.scope == scope: + return ts + return None + + @staticmethod + def _upsert_token_set( + context: AnonymousSessionContext, + entry: AnonymousTokenSetEntry, + ) -> AnonymousSessionContext: + new_sets = [ + ts for ts in context.token_sets + if not (ts.audience == entry.audience and ts.scope == entry.scope) + ] + new_sets.append(entry) + return context.model_copy(update={"token_sets": new_sets}) + # ============================================================================ # ERROR HANDLING # ============================================================================ @@ -195,7 +237,7 @@ def _map_anonymous_error( Args: status_code: The HTTP status code of the response. error_data: The parsed error response body. - operation: One of 'create', 'token', 'logout', 'introspect'. + operation: One of 'create', 'token', 'introspect'. Returns: The exception instance. Does not raise it. @@ -220,8 +262,6 @@ def _map_anonymous_error( return AnonymousSessionCreateError(description, cause=error_data) if operation == "token": return AnonymousSessionTokenError(description, error_data) - if operation == "logout": - return AnonymousSessionLogoutError(description, error_data) if operation == "introspect": return AnonymousSessionIntrospectError(description, error_data) return AnonymousSessionApiError(code or "anonymous_error", description, error_data) @@ -294,7 +334,7 @@ def _decrypt_context(self, stored: Any) -> AnonymousSessionContext: if not encrypted: raise ValueError("Malformed anonymous session record") payload = decrypt(encrypted, self._secret, ANON_TOKEN_SALT) - return AnonymousSessionContext(**payload) + return AnonymousSessionContext.model_validate(payload) except Exception as e: raise _AnonymousSessionExpired( "Stored anonymous session token is invalid or corrupted." @@ -330,19 +370,19 @@ async def _create_session_at( was invalid or missing required fields. """ base_url = f"https://{domain}" - body: dict[str, Any] = {"client_id": self._client_id} + payload: dict[str, Any] = {"client_id": self._client_id} if self._client_secret: - body["client_secret"] = self._client_secret + payload["client_secret"] = self._client_secret if audience: - body["audience"] = audience + payload["audience"] = audience if scope: - body["scope"] = scope + payload["scope"] = scope if metadata: - body["metadata"] = metadata + payload["metadata"] = metadata async with self._get_http_client() as client: try: - response = await client.post(f"{base_url}/anonymous/token", json=body) + response = await client.post(f"{base_url}/anonymous/token", json=payload) except httpx.HTTPError as e: raise AnonymousSessionCreateError("Failed to reach the anonymous token endpoint") from e @@ -355,32 +395,37 @@ async def _create_session_at( raise mapped try: - body = response.json() + data = response.json() except (json.JSONDecodeError, ValueError) as e: raise AnonymousSessionCreateError("Failed to parse anonymous token response") from e - if isinstance(body, dict) and not body.get("session_token"): + if not isinstance(data, dict) or not data.get("session_token"): raise AnonymousSessionCreateError( "Anonymous token response contained no session_token. Enable session_token in the response for this tenant.", code="missing_session_token", ) try: - token_response = AnonymousCreateTokenResponse.model_validate(body) + token_response = AnonymousCreateTokenResponse.model_validate(data) except (ValueError, ValidationError) as e: raise AnonymousSessionCreateError("Failed to parse anonymous token response") from e now = int(time.time()) - context = AnonymousSessionContext( - session_token=token_response.session_token, + sub = self._decode_sub(token_response.access_token) + token_set = AnonymousTokenSetEntry( access_token=token_response.access_token, expires_at=now + token_response.expires_in, + audience=audience, + scope=scope, + ) + context = AnonymousSessionContext( + session_token=token_response.session_token, + token_sets=[token_set], session_expires_at=now + token_response.session_expires_in, metadata=metadata, created_at=now, domain=domain, - audience=audience, - scope=scope, + sub=sub, ) await self._anonymous_store.set( ANON_IDENTIFIER, @@ -388,11 +433,12 @@ async def _create_session_at( options=store_options, ) return AnonymousSession( - access_token=context.access_token, + access_token=token_set.access_token, session_token=context.session_token, - expires_at=context.expires_at, + expires_at=token_set.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, + sub=context.sub, ) # ============================================================================ @@ -400,12 +446,18 @@ async def _create_session_at( # ============================================================================ async def _remint( - self, context: AnonymousSessionContext, store_options: Optional[dict[str, Any]] + self, + context: AnonymousSessionContext, + audience: Optional[str], + scope: Optional[str], + store_options: Optional[dict[str, Any]], ) -> AnonymousSession: """Re-mint an access token using the stored session token. Args: context: The current decrypted session context. + audience: Audience to request for the new token. + scope: Scope to request for the new token. store_options: Options passed to the anonymous store. Returns: @@ -416,22 +468,20 @@ async def _remint( 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, } if self._client_secret: body["client_secret"] = self._client_secret - # Re-mint must replay audience/scope. The endpoint doesn't remember them. - if context.audience: - body["audience"] = context.audience - if context.scope: - body["scope"] = context.scope + if audience: + body["audience"] = audience + if scope: + body["scope"] = scope async with self._get_http_client() as client: try: - response = await client.post(f"{base_url}/anonymous/token", json=body) + response = await client.post(f"https://{domain}/anonymous/token", json=body) except httpx.HTTPError as e: raise AnonymousSessionTokenError("Failed to reach the anonymous token endpoint") from e @@ -439,11 +489,11 @@ async def _remint( 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. + # One follow-up create call on expiry, never a loop. return await self._create_session_at( domain, - audience=context.audience, - scope=context.scope, + audience=audience, + scope=scope, metadata=None, store_options=store_options, ) @@ -455,33 +505,55 @@ async def _remint( raise AnonymousSessionTokenError("Failed to parse anonymous token response") from e now = int(time.time()) - new_context = AnonymousSessionContext( - session_token=( - token_response.session_token - if token_response.session_token is not None - else context.session_token - ), + new_session_token = ( + token_response.session_token + if token_response.session_token is not None + else context.session_token + ) + new_sub = self._decode_sub(token_response.access_token) + token_set = AnonymousTokenSetEntry( access_token=token_response.access_token, expires_at=now + token_response.expires_in, + audience=audience, + scope=scope, + ) + result = AnonymousSession( + access_token=token_set.access_token, + session_token=new_session_token, + expires_at=token_set.expires_at, session_expires_at=now + token_response.session_expires_in, metadata=context.metadata, - created_at=context.created_at, - domain=domain, - audience=context.audience, - scope=context.scope, + sub=new_sub if new_sub is not None else context.sub, + ) + + # Re-read before writing back so concurrent remints for other audiences + # are preserved, and writes to a deleted or replaced session are skipped. + current_stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + if not current_stored: + return result + try: + current_context = self._decrypt_context(current_stored) + except _AnonymousSessionExpired: + current_context = context + if current_context.session_token != context.session_token: + return result + + # Only update sub when it was not previously stored (token was initially JWE). + sub_update = {"sub": new_sub} if new_sub is not None and current_context.sub is None else {} + updated_context = self._upsert_token_set( + current_context.model_copy(update={ + "session_token": new_session_token, + "session_expires_at": now + token_response.session_expires_in, + **sub_update, + }), + token_set, ) await self._anonymous_store.set( ANON_IDENTIFIER, - {"context": self._encrypt_context(new_context)}, + {"context": self._encrypt_context(updated_context)}, options=store_options, ) - return AnonymousSession( - access_token=new_context.access_token, - session_token=new_context.session_token, - expires_at=new_context.expires_at, - session_expires_at=new_context.session_expires_at, - metadata=new_context.metadata, - ) + return result # ============================================================================ # LOGIN INJECTION SUPPORT @@ -492,13 +564,6 @@ async def exchange_transfer_token_for_injection( ) -> Optional[str]: """Mint a short-lived transfer ticket from the active session for login injection. - Reads the stored session without renewing it, then exchanges it for a - 30s ``anon_transfer_token``. Fails open (returns None) when there is no - store, no active session, the record cannot be decrypted, or the - exchange fails. Fails closed (returns None) on an MCD domain mismatch, - so a ticket is never minted for a host the session was not created - against. The ticket is never persisted. - Args: origin_domain: The domain the /authorize URL is being built for. store_options: Options passed to the anonymous store. @@ -518,8 +583,7 @@ async def exchange_transfer_token_for_injection( context = self._decrypt_context(stored) except _AnonymousSessionExpired: return None - # MCD fail-closed: never mint a ticket for a host the session was not - # created against. + # Domain mismatch rejects a session belonging to a different tenant. if context.domain and self._normalize_url(context.domain) != self._normalize_url( origin_domain ): @@ -531,10 +595,6 @@ async def _mint_transfer_token( ) -> Optional[str]: """Exchange a session token for a transfer ticket. Fails open. - Posts to /anonymous/token with the transfer audience and returns the - ``anon_transfer_token``, or None on any HTTP, non-200, or parse error, - matching the platform's fail-open redemption. Never persists the ticket. - Args: session_token: The stored anonymous session token. origin_domain: The domain the /authorize URL is being built for. @@ -603,7 +663,7 @@ async def create_session( if options is not None: if isinstance(options, dict): try: - options = CreateAnonymousSessionOptions(**options) + options = CreateAnonymousSessionOptions.model_validate(options) except ValidationError as e: raise AnonymousSessionCreateError( "Invalid create_session options", code="invalid_options" @@ -619,11 +679,21 @@ 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, + *, + audience: Optional[str] = None, + scope: Optional[str] = None, + ) -> AnonymousSession: """Return a valid anonymous access token, renewing or re-minting as needed. Args: store_options: Options passed to the anonymous store. + audience: Audience to retrieve a token for. Falls back to the + client's configured default when omitted. + scope: Scope to retrieve a token for. Falls back to the client's + configured default when omitted. Returns: The current or refreshed AnonymousSession. @@ -638,14 +708,17 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano if not stored: raise AnonymousSessionTokenError("No active anonymous session. Call create_session() first.") + eff_audience = audience or self._default_audience + eff_scope = scope or self._default_scope + try: context = self._decrypt_context(stored) except _AnonymousSessionExpired: domain = await self._resolve_domain(store_options) return await self._create_session_at( domain, - audience=self._default_audience, - scope=self._default_scope, + audience=eff_audience, + scope=eff_scope, metadata=None, store_options=store_options, ) @@ -656,23 +729,25 @@ async def get_token(self, store_options: Optional[dict[str, Any]] = None) -> Ano ): return await self._create_session_at( current_domain, - audience=context.audience, - scope=context.scope, + audience=eff_audience, + scope=eff_scope, metadata=context.metadata, store_options=store_options, ) now = int(time.time()) - if context.expires_at > now: + token_set = self._find_token_set(context.token_sets, eff_audience, eff_scope) + if token_set and token_set.expires_at > now: return AnonymousSession( - access_token=context.access_token, + access_token=token_set.access_token, session_token=context.session_token, - expires_at=context.expires_at, + expires_at=token_set.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, + sub=context.sub, ) - return await self._remint(context, store_options) + return await self._remint(context, eff_audience, eff_scope, store_options) async def introspect( self, store_options: Optional[dict[str, Any]] = None @@ -709,7 +784,7 @@ async def introspect( try: response = await client.get( f"{base_url}/anonymous/userinfo", - auth=BearerAuth(context.access_token), + auth=BearerAuth(context.session_token), ) except httpx.HTTPError as e: raise AnonymousSessionIntrospectError( @@ -733,70 +808,23 @@ 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) if not stored: return - - try: - context = self._decrypt_context(stored) - 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}" - auth = ( - (self._client_id, self._client_secret) if self._client_secret else None - ) - try: - async with self._get_http_client() as client: - response = await client.post( - f"{base_url}/anonymous/logout", json={}, auth=auth - ) - except httpx.HTTPError as e: - error_to_raise = AnonymousSessionLogoutError( - "Failed to reach the anonymous logout endpoint" - ) - error_cause = e - else: - if not 200 <= response.status_code < 300: - 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 - - async def end_session_if_active( + async def _end_session_if_active( self, store_options: Optional[dict[str, Any]] = None ) -> None: """Clear the local anonymous session on authenticated logout, if one is active. - Reads the anonymous store fail-soft and, when a session is present, - deletes only the locally-held encrypted context. Makes no remote call: - there is no server-side anonymous session store to revoke against, and - the local delete is what closes the shared-device re-injection path - (without it the next login through this store would re-inject the - previous visitor's anonymous identity). - Args: store_options: Options passed to the anonymous store. """ @@ -808,4 +836,47 @@ async def end_session_if_active( return if not stored: return - await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) + try: + await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) + except Exception: + return + + async def get_session( + self, store_options: Optional[dict[str, Any]] = None + ) -> Optional[AnonymousSessionData]: + """Return stored anonymous session identity without calling Auth0. + + Args: + store_options: Options passed to the anonymous store. + + Returns: + The stored AnonymousSessionData, or None when there is no session, + the session is corrupt, or (in resolver mode) the stored domain + does not match the current tenant. + """ + 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 + if context.domain: + try: + current_domain = await self._resolve_domain(store_options) + except Exception: + return None + if self._normalize_url(context.domain) != self._normalize_url(current_domain): + return None + return AnonymousSessionData( + sub=context.sub, + metadata=context.metadata, + created_at=context.created_at, + session_expires_at=context.session_expires_at, + domain=context.domain, + ) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 0108eeaf..04894f7a 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -167,6 +167,7 @@ def __init__( enterprise_connect: bool = False, use_mtls: bool = False, ssl_context: Optional[ssl.SSLContext] = None, + clear_anonymous_session_on_login: bool = False, ): """ Initialize the Auth0 server client. @@ -208,6 +209,11 @@ def __init__( ssl_context: TLS context carrying the client certificate and key. Required when use_mtls=True. Build with ssl.create_default_context() and load_cert_chain(). + clear_anonymous_session_on_login: When True and an anonymous_store is + configured, the anonymous session is cleared via anonymous.logout() + after a successful interactive login (complete_interactive_login). + Defaults to False. The logout call is best-effort and never fails + an otherwise-successful login. Raises: ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds. @@ -257,6 +263,11 @@ def __init__( "use_mtls cannot be combined with client_assertion_signing_key. " "The client certificate is the sole credential under mTLS." ) + if anonymous_store is not None: + raise ConfigurationError( + "Anonymous Sessions do not support mTLS. The AnonymousClient has no " + "client-certificate credential path." + ) self._client_id = client_id self._client_secret = client_secret @@ -272,6 +283,7 @@ def __init__( self._pushed_authorization_requests = pushed_authorization_requests # store the flag self._organization = organization self._enterprise_connect = enterprise_connect + self._clear_anonymous_session_on_login = clear_anonymous_session_on_login self._webfinger_cache: OrderedDict[str, dict] = OrderedDict() # Initialize stores @@ -837,12 +849,7 @@ async def start_interactive_login( if options.invitation: auth_params["invitation"] = options.invitation - # The anonymous transfer ticket is minted late from the SDK's own - # encrypted anonymous store, never a caller. The pops strip any value - # seeded from the constructor defaults, closing a session-fixation - # vector. PAR and Enterprise Connect suppress injection entirely. The - # ticket rides the query string but is short-lived (30s) and single-use - # in effect; the raw session_token is never placed on the URL. + # Pops close the session-fixation vector from constructor-seeded defaults. auth_params.pop("session_token", None) auth_params.pop("anon_transfer_token", None) if not self._pushed_authorization_requests and not self._enterprise_connect: @@ -1115,6 +1122,7 @@ async def complete_interactive_login( # Clean up transaction data after successful login await self._transaction_store.delete(transaction_identifier, options=store_options) + await self._clear_anonymous_session_after_login(store_options) result = {"state_data": state_data.dict()} if transaction_data.app_state: @@ -1322,6 +1330,23 @@ async def _establish_session_from_mfa_verify_response( store_options=store_options, ) + async def _clear_anonymous_session_after_login( + self, store_options: Optional[dict[str, Any]] = None + ) -> None: + """Clear the anonymous session after a successful interactive login. + + Args: + store_options: Options passed to the anonymous store. + """ + if not self._clear_anonymous_session_on_login: + return + if self._anonymous_store is None: + return + try: + await self._anonymous_client.logout(store_options) + except Exception as e: + logger.debug("Anonymous session cleanup after login failed: %s", e) + # ============================================================================ # USER SESSION MANAGEMENT # Methods for retrieving user information, session data, and logout operations. @@ -1451,15 +1476,12 @@ async def logout( if session_domain and self._normalize_url(session_domain) == self._normalize_url(domain): await self._state_store.delete(self._state_identifier, store_options) - # End any active anonymous session on authenticated logout, closing the - # shared-device re-injection path. Best-effort: anonymous cleanup must - # never break the authenticated logout, so any failure is swallowed. + # End any active anonymous session on authenticated logout to close the + # shared-device re-injection path. Failures are swallowed. if self._anonymous_store is not None: try: - await self._anonymous_client.end_session_if_active(store_options) + await self._anonymous_client._end_session_if_active(store_options) except Exception as e: - # Best-effort: anonymous cleanup must never break the - # authenticated logout. The anon errors carry no token. logger.debug("Anonymous session cleanup on logout failed: %s", e) # Return logout URL for the current resolved domain diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 61366c47..34d4bed4 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -893,6 +893,21 @@ class AnonymousSession(BaseModel): expires_at: int session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None + sub: Optional[str] = None + + +class AnonymousSessionData(BaseModel): + """Local session state returned by AnonymousClient.get_session(). + + Contains identity and metadata fields only. session_token is never + included; use get_token() when a bearer token is needed. + """ + + sub: Optional[str] = None + metadata: Optional[dict[str, Any]] = None + created_at: int + session_expires_at: Optional[int] = None + domain: Optional[str] = None class AnonymousSessionIntrospection(BaseModel): @@ -916,20 +931,13 @@ class AnonymousTokenResponse(BaseModel): class AnonymousCreateTokenResponse(AnonymousTokenResponse): - """Raw response from POST /anonymous/token on the create path. - - session_token is always present on creation, unlike on re-mint. - """ + """Raw response from POST /anonymous/token on the create path.""" session_token: str class AnonymousTransferTokenResponse(BaseModel): - """Raw response from POST /anonymous/token on the transfer-ticket path. - - The exchange returns a short-lived ticket (token_type "N_A") carried on - the /authorize URL, not a bearer token. Lenient to unrecognized fields. - """ + """Raw response from POST /anonymous/token on the transfer-ticket path.""" model_config = ConfigDict(extra="ignore") anon_transfer_token: str @@ -937,21 +945,32 @@ class AnonymousTransferTokenResponse(BaseModel): expires_in: Optional[int] = None +class AnonymousTokenSetEntry(BaseModel): + """One cached access token for a specific audience/scope pair.""" + + model_config = ConfigDict(extra="forbid") + + access_token: str + expires_at: int + audience: Optional[str] = None + scope: Optional[str] = None + + class AnonymousSessionContext(BaseModel): """Internal context stored inside the encrypted anonymous session record. Rejects extra fields so a tampered payload fails closed on decrypt. """ + model_config = ConfigDict(extra="forbid") + session_token: str - access_token: str - expires_at: int + token_sets: list[AnonymousTokenSetEntry] = Field(default_factory=list) session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None created_at: int domain: Optional[str] = None - audience: Optional[str] = None - scope: Optional[str] = None + sub: Optional[str] = None # ============================================================================= diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 95bee7cb..198217ed 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -508,15 +508,13 @@ def __init__(self, message: str, cause: Optional[dict] = None): class _AnonymousSessionExpired(Auth0Error): - """Internal-only signal that the stored session token is expired or invalid. - - Never raised to SDK callers. - """ + """Internal-only signal that the stored session token is expired or invalid.""" def __init__(self, message: str = "The anonymous session token is expired or invalid."): super().__init__(message) self.name = "_AnonymousSessionExpired" +# ============================================================================= # Enterprise Connect Error Classes # ============================================================================= diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index db4991e6..26a302fc 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -2,6 +2,7 @@ Tests for AnonymousClient, covering anonymous session API operations. """ +import base64 as _b64 import inspect import time from unittest.mock import AsyncMock, MagicMock, patch @@ -15,6 +16,8 @@ ) from auth0_server_python.auth_types import ( AnonymousSessionContext, + AnonymousSessionData, + AnonymousTokenSetEntry, CreateAnonymousSessionOptions, ) from auth0_server_python.encryption.encrypt import encrypt @@ -24,7 +27,6 @@ AnonymousSessionCreateError, AnonymousSessionFeatureNotEnabledError, AnonymousSessionIntrospectError, - AnonymousSessionLogoutError, AnonymousSessionResourceServerError, AnonymousSessionScopeError, AnonymousSessionTokenError, @@ -98,15 +100,30 @@ def _token_response( } +def _make_jwt(sub: str = "anon@test-uuid") -> str: + """Build a minimal unsigned JWT with the given sub claim.""" + header = _b64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = _b64.urlsafe_b64encode(f'{{"sub":"{sub}"}}'.encode()).rstrip(b"=").decode() + return f"{header}.{payload}." + + def _stored_context(store: OneSlotStore, **overrides): - defaults = { - "session_token": "ST1", + ts_keys = {"access_token", "expires_at", "audience", "scope"} + ts_defaults = { "access_token": "AT1", "expires_at": int(time.time()) + 3600, + } + ctx_defaults = { + "session_token": "ST1", "created_at": int(time.time()), } - defaults.update(overrides) - context = AnonymousSessionContext(**defaults) + for k in list(overrides): + if k in ts_keys: + ts_defaults[k] = overrides.pop(k) + else: + ctx_defaults[k] = overrides.pop(k) + token_set = AnonymousTokenSetEntry(**ts_defaults) + context = AnonymousSessionContext(token_sets=[token_set], **ctx_defaults) encrypted = encrypt(context.model_dump(), SECRET, "anon_session") store.slot = (ANON_IDENTIFIER, {"context": encrypted}) return context @@ -440,6 +457,44 @@ async def post(self, *a, **k): await client.create_session(audience="aud", scope="s") + @pytest.mark.asyncio + async def test_create_session_stores_and_returns_sub_from_jwt(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + jwt_token = _make_jwt("anon@test-uuid") + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "session_token": "ST1", + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session() + assert session.sub == "anon@test-uuid" + stored = await store.get(ANON_IDENTIFIER) + ctx = client._decrypt_context(stored) + assert ctx.sub == "anon@test-uuid" + + @pytest.mark.asyncio + async def test_create_session_stores_none_sub_for_opaque_token(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": "opaque.token.without.claims.here", + "token_type": "Bearer", + "expires_in": 3600, + "session_token": "ST1", + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session() + assert session.sub is None + stored = await store.get(ANON_IDENTIFIER) + ctx = client._decrypt_context(stored) + assert ctx.sub is None + + # ── get_token (renewal ladder) ──────────────────────────────────────────────── class TestGetToken: @@ -484,15 +539,10 @@ async def test_expired_access_token_remints_via_session_token_grant(self): assert "refresh_token" not in kwargs["json"] @pytest.mark.asyncio - async def test_remint_replays_audience_and_scope_from_the_stored_session(self): - """Re-mint request must replay the session's stored audience and scope.""" + async def test_remint_replays_audience_and_scope_from_get_token_params(self): + """Re-mint request must include the audience/scope passed to get_token.""" store = OneSlotStore() - _stored_context( - store, - expires_at=int(time.time()) - 10, - audience="https://api.example.com", - scope="read:things", - ) + _stored_context(store, expires_at=int(time.time()) - 10) client = _make_client(anonymous_store=store) fake_http = _FakeAsyncClient([ _fake_response( @@ -506,7 +556,7 @@ async def test_remint_replays_audience_and_scope_from_the_stored_session(self): ) ]) with patch("httpx.AsyncClient", fake_http): - await client.get_token() + await client.get_token(audience="https://api.example.com", scope="read:things") _, _, kwargs = fake_http.calls[0] assert kwargs["json"]["audience"] == "https://api.example.com" assert kwargs["json"]["scope"] == "read:things" @@ -659,6 +709,223 @@ async def test_get_token_never_writes_to_authenticated_state_store(self): auth_state_store.get.assert_not_called() auth_state_store.delete.assert_not_called() + @pytest.mark.asyncio + async def test_second_audience_is_cached_without_evicting_first(self): + """get_token for a different audience upserts rather than replacing.""" + store = OneSlotStore() + _stored_context(store, audience="https://api1.example.com") + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(200, { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }) + ]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token(audience="https://api2.example.com") + + stored = await store.get(ANON_IDENTIFIER) + context = client._decrypt_context(stored) + audiences = [ts.audience for ts in context.token_sets] + assert "https://api1.example.com" in audiences + assert "https://api2.example.com" in audiences + + @pytest.mark.asyncio + async def test_cached_token_returned_for_correct_audience(self): + """get_token returns the cached token for the matching audience without a network call.""" + store = OneSlotStore() + _stored_context(store, audience="https://api1.example.com", access_token="AT_API1") + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + session = await client.get_token(audience="https://api1.example.com") + mock_http.assert_not_called() + assert session.access_token == "AT_API1" + + @pytest.mark.asyncio + async def test_different_audience_causes_remint_not_cache_hit(self): + """A stored token for api1 does not satisfy a request for api2.""" + store = OneSlotStore() + _stored_context(store, audience="https://api1.example.com", access_token="AT_API1") + client = _make_client(anonymous_store=store) + fake_http = _FakeAsyncClient([ + _fake_response(200, { + "access_token": "AT_API2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }) + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token(audience="https://api2.example.com") + assert session.access_token == "AT_API2" + assert len(fake_http.calls) == 1 + + @pytest.mark.asyncio + async def test_concurrent_remint_preserves_other_audience_token(self): + """A token for api2 written to the store before our re-read is not lost.""" + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + + remint_response = { + "access_token": "AT_API1", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + } + + original_get = store.get + original_set = store.set + get_call_count = 0 + + async def get_with_concurrent_write(identifier, *, options=None): + nonlocal get_call_count + get_call_count += 1 + if get_call_count == 2: + # Simulate a concurrent remint for api2 completing during our HTTP call, + # i.e. before our re-read runs. + current = await original_get(identifier) + ctx = client._decrypt_context(current) + concurrent_token_set = AnonymousTokenSetEntry( + access_token="AT_API2", + expires_at=int(time.time()) + 3600, + audience="https://api2.example.com", + ) + merged = client._upsert_token_set(ctx, concurrent_token_set) + await original_set( + identifier, + {"context": encrypt(merged.model_dump(), SECRET, "anon_session")}, + ) + return await original_get(identifier) + + store.get = get_with_concurrent_write + + fake_http = _FakeAsyncClient([_fake_response(200, remint_response)]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token(audience="https://api1.example.com") + + stored = await store.get(ANON_IDENTIFIER) + final_ctx = client._decrypt_context(stored) + audiences = [ts.audience for ts in final_ctx.token_sets] + assert "https://api2.example.com" in audiences + assert "https://api1.example.com" in audiences + + @pytest.mark.asyncio + async def test_remint_skips_write_when_session_deleted_during_fetch(self): + """If the session is deleted while the HTTP call is in flight, the write is skipped.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + + original_get = store.get + call_count = 0 + + async def get_and_delete(identifier, *, options=None): + nonlocal call_count + call_count += 1 + if call_count == 2: + store.slot = None + return await original_get(identifier) + + store.get = get_and_delete + fake_http = _FakeAsyncClient([ + _fake_response(200, { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }) + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + + assert session is not None + assert store.slot is None + + @pytest.mark.asyncio + async def test_remint_skips_write_when_session_replaced_during_fetch(self): + """If the session token changed while in flight (recreation), the write is skipped.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + + original_get = store.get + call_count = 0 + + async def get_and_replace(identifier, *, options=None): + nonlocal call_count + call_count += 1 + if call_count == 2: + _stored_context(store, session_token="NEW_SESSION_TOKEN") + return await original_get(identifier) + + store.get = get_and_replace + fake_http = _FakeAsyncClient([ + _fake_response(200, { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }) + ]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + + assert session is not None + stored = await store.get(ANON_IDENTIFIER) + final_ctx = client._decrypt_context(stored) + assert final_ctx.session_token == "NEW_SESSION_TOKEN" + + @pytest.mark.asyncio + async def test_get_token_returns_sub_from_cache(self): + store = OneSlotStore() + _stored_context(store, sub="anon@cached-uuid") + 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.sub == "anon@cached-uuid" + + @pytest.mark.asyncio + async def test_remint_returns_and_stores_sub_from_new_token(self): + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + jwt_token = _make_jwt("anon@reminted-uuid") + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.sub == "anon@reminted-uuid" + stored = await store.get(ANON_IDENTIFIER) + ctx = client._decrypt_context(stored) + assert ctx.sub == "anon@reminted-uuid" + + @pytest.mark.asyncio + async def test_remint_does_not_overwrite_existing_sub(self): + """Once sub is stored, a remint that returns a readable token must not replace it.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10, sub="anon@original-uuid") + client = _make_client(anonymous_store=store) + jwt_token = _make_jwt("anon@should-be-ignored") + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + stored = await store.get(ANON_IDENTIFIER) + ctx = client._decrypt_context(stored) + assert ctx.sub == "anon@original-uuid" + # ── MCD / cross-tenant isolation ─────────────────────────────────────────────── @@ -798,64 +1065,16 @@ 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() + await client.logout() assert store.slot is None - @pytest.mark.asyncio - async def test_logout_sends_empty_body_and_authenticates_via_header(self): - """logout() must send an empty body and authenticate via the Authorization header.""" - store = OneSlotStore() - _stored_context(store) - client = _make_client(anonymous_store=store) - fake_http = _FakeAsyncClient([_fake_response(204, {})]) - with patch("httpx.AsyncClient", fake_http): - await client.logout() - _, url, kwargs = fake_http.calls[0] - assert url.endswith("/anonymous/logout") - assert kwargs["json"] == {} - assert kwargs["auth"] == (CLIENT_ID, CLIENT_SECRET) - - @pytest.mark.asyncio - async def test_logout_treats_204_no_content_as_success(self): - """Auth0 answers 204 No Content on success.""" - store = OneSlotStore() - _stored_context(store) - client = _make_client(anonymous_store=store) - fake_http = _FakeAsyncClient([_fake_response(204, {})]) - with patch("httpx.AsyncClient", fake_http): - await client.logout() # must not raise - assert store.slot is None - - @pytest.mark.asyncio - async def test_logout_without_client_secret_sends_no_client_auth(self): - """A public client has no secret to present - omit auth, don't send None fields.""" - store = OneSlotStore() - _stored_context(store) - client = AnonymousClient( - domain=DOMAIN, - client_id=CLIENT_ID, - client_secret=None, - secret=SECRET, - anonymous_store=store, - ) - fake_http = _FakeAsyncClient([_fake_response(204, {})]) - with patch("httpx.AsyncClient", fake_http): - await client.logout() - _, _, kwargs = fake_http.calls[0] - assert kwargs["auth"] is None - assert kwargs["json"] == {} - @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() + await client.logout() auth_store.delete.assert_not_called() @pytest.mark.asyncio @@ -863,9 +1082,7 @@ 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() + await client.logout() with pytest.raises(AnonymousSessionTokenError): await client.get_token() @@ -873,67 +1090,15 @@ async def test_get_token_after_logout_behaves_as_no_session(self): 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_still_clears_local_state_then_raises(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()): - 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() + 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 - - @pytest.mark.asyncio - async def test_logout_corrupted_context_clears_state_without_server_call(self): + async def test_logout_corrupted_context_clears_state(self): store = OneSlotStore() store.slot = (ANON_IDENTIFIER, {"context": "not-a-decryptable-blob"}) client = _make_client(anonymous_store=store) - with patch("httpx.AsyncClient") as mock_http: - await client.logout() - mock_http.assert_not_called() + await client.logout() assert store.slot is None @@ -1090,14 +1255,14 @@ async def post(self, *a, **k): assert captured.get("Auth0-Client") == "abc" -# ── end_session_if_active (end anon session on authenticated logout) ──────────── +# ── _end_session_if_active (end anon session on authenticated logout) ──────────── class TestEndSessionIfActive: @pytest.mark.asyncio async def test_no_store_is_noop(self): client = _make_client(anonymous_store=None) with patch("httpx.AsyncClient") as mock_http: - await client.end_session_if_active() + await client._end_session_if_active() mock_http.assert_not_called() @pytest.mark.asyncio @@ -1105,7 +1270,7 @@ async def test_no_session_makes_no_remote_call(self): store = OneSlotStore() client = _make_client(anonymous_store=store) with patch("httpx.AsyncClient") as mock_http: - await client.end_session_if_active() + await client._end_session_if_active() mock_http.assert_not_called() @pytest.mark.asyncio @@ -1114,7 +1279,7 @@ async def test_active_session_clears_local_without_remote_call(self): _stored_context(store, domain="auth0.local") client = _make_client(anonymous_store=store) with patch("httpx.AsyncClient") as mock_http: - await client.end_session_if_active() + await client._end_session_if_active() mock_http.assert_not_called() assert store.slot is None @@ -1123,4 +1288,88 @@ async def test_store_exception_is_swallowed(self): store = AsyncMock() store.get = AsyncMock(side_effect=RuntimeError("store unavailable")) client = _make_client(anonymous_store=store) - await client.end_session_if_active() # must not raise + await client._end_session_if_active() # must not raise + + +# ── get_session ─────────────────────────────────────────────────────────────── + +class TestGetSession: + @pytest.mark.asyncio + async def test_returns_none_when_no_store_configured(self): + client = _make_client() + result = await client.get_session() + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_no_session_stored(self): + store = OneSlotStore() + client = _make_client(anonymous_store=store) + result = await client.get_session() + assert result is None + + @pytest.mark.asyncio + async def test_returns_session_data_with_identity_fields(self): + store = OneSlotStore() + _stored_context(store, sub="anon@test-uuid", domain="auth0.local") + client = _make_client(anonymous_store=store) + result = await client.get_session() + assert isinstance(result, AnonymousSessionData) + assert result.sub == "anon@test-uuid" + assert result.domain == "auth0.local" + + @pytest.mark.asyncio + async def test_does_not_include_session_token(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + result = await client.get_session() + assert not hasattr(result, "session_token") or not isinstance(getattr(result, "session_token", None), str) + + @pytest.mark.asyncio + async def test_makes_no_http_call(self): + store = OneSlotStore() + _stored_context(store) + client = _make_client(anonymous_store=store) + with patch("httpx.AsyncClient") as mock_http: + await client.get_session() + mock_http.assert_not_called() + + @pytest.mark.asyncio + async def test_returns_none_for_corrupt_session(self): + store = OneSlotStore() + store.slot = (ANON_IDENTIFIER, {"context": "not-valid-jwe"}) + client = _make_client(anonymous_store=store) + result = await client.get_session() + assert result is None + + @pytest.mark.asyncio + async def test_returns_none_when_store_raises(self): + store = AsyncMock() + store.get = AsyncMock(side_effect=RuntimeError("store down")) + client = _make_client(anonymous_store=store) + result = await client.get_session() + assert result is None + + @pytest.mark.asyncio + async def test_domain_mismatch_in_resolver_mode_returns_none(self): + store = OneSlotStore() + _stored_context(store, domain="tenant-a.auth0.local") + resolver = AsyncMock(return_value="tenant-b.auth0.local") + client = AnonymousClient( + domain=resolver, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, + secret=SECRET, anonymous_store=store, + ) + result = await client.get_session() + assert result is None + + @pytest.mark.asyncio + async def test_domain_match_in_resolver_mode_returns_session(self): + store = OneSlotStore() + _stored_context(store, domain="tenant-a.auth0.local") + resolver = AsyncMock(return_value="tenant-a.auth0.local") + client = AnonymousClient( + domain=resolver, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, + secret=SECRET, anonymous_store=store, + ) + result = await client.get_session() + assert result is not None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 7fe231c6..01bffc86 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -28,6 +28,7 @@ ) from auth0_server_python.auth_types import ( AnonymousSessionContext, + AnonymousTokenSetEntry, CompleteConnectAccountRequest, ConnectAccountOptions, ConnectAccountRequest, @@ -9913,16 +9914,22 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker def _make_anon_context(secret, **overrides): - defaults = { - "session_token": "ANON_TOKEN_1", - "sub": "anon@abc", - "session_id": "sid1", + ts_keys = {"access_token", "expires_at", "audience", "scope"} + ts_defaults = { "access_token": "anon_at1", "expires_at": int(time.time()) + 3600, + } + ctx_defaults = { + "session_token": "ANON_TOKEN_1", "created_at": int(time.time()), } - defaults.update(overrides) - context = AnonymousSessionContext(**defaults) + for k in list(overrides): + if k in ts_keys: + ts_defaults[k] = overrides.pop(k) + else: + ctx_defaults[k] = overrides.pop(k) + token_set = AnonymousTokenSetEntry(**ts_defaults) + context = AnonymousSessionContext(token_sets=[token_set], **ctx_defaults) return encrypt(context.model_dump(), secret, "anon_session") @@ -10375,6 +10382,113 @@ def fake_create_url(endpoint, **kwargs): # ── end anonymous session on authenticated logout ─────────────────────────────── +# ── clear_anonymous_session_on_login ──────────────────────────────────────────── + + +def _setup_complete_interactive_login(client, mocker): + """Patch the minimum set of collaborators needed for complete_interactive_login to succeed.""" + mocker.patch.object( + client, + "_get_oidc_metadata_cached", + return_value={"issuer": "https://auth0.local/", "token_endpoint": "https://auth0.local/token"}, + ) + mocker.patch.object(client, "_get_jwks_cached", return_value={"keys": [{"kty": "RSA", "kid": "k1"}]}) + mocker.patch.object( + client._oauth, + "fetch_token", + AsyncMock(return_value={"access_token": "at1", "id_token": "id_jwt", "scope": "openid"}), + ) + mocker.patch("jwt.get_unverified_header", return_value={"kid": "k1"}) + mock_key = mocker.MagicMock() + mock_key.key = "pem" + mocker.patch("jwt.PyJWK.from_dict", return_value=mock_key) + mocker.patch( + "jwt.decode", + return_value={"sub": "u1", "iss": "https://auth0.local/", "aud": "cid"}, + ) + + +@pytest.mark.asyncio +async def test_complete_interactive_login_does_not_clear_anonymous_session_by_default(mocker): + """Default (clear_anonymous_session_on_login=False): anonymous session is not touched on login.""" + secret = "a-test-secret-with-enough-length" + anon_store = OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + tx_store = AsyncMock() + tx_store.get.return_value = TransactionData(code_verifier="cv1", domain="auth0.local") + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + transaction_store=tx_store, + state_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + ) + _setup_complete_interactive_login(client, mocker) + logout_spy = mocker.patch.object(client._anonymous_client, "logout", AsyncMock()) + + await client.complete_interactive_login("https://auth0.local/cb?code=c&state=s") + + logout_spy.assert_not_awaited() + assert anon_store.slot is not None + + +@pytest.mark.asyncio +async def test_complete_interactive_login_clears_anonymous_session_when_enabled(mocker): + """clear_anonymous_session_on_login=True: anonymous.logout() is called after session is written.""" + secret = "a-test-secret-with-enough-length" + anon_store = OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + tx_store = AsyncMock() + tx_store.get.return_value = TransactionData(code_verifier="cv1", domain="auth0.local") + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + transaction_store=tx_store, + state_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + clear_anonymous_session_on_login=True, + ) + _setup_complete_interactive_login(client, mocker) + logout_spy = mocker.patch.object(client._anonymous_client, "logout", AsyncMock()) + + result = await client.complete_interactive_login("https://auth0.local/cb?code=c&state=s") + + assert "state_data" in result + logout_spy.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_complete_interactive_login_cleanup_failure_does_not_fail_login(mocker): + """A failing anonymous.logout() is swallowed and must never fail an otherwise-successful login.""" + secret = "a-test-secret-with-enough-length" + anon_store = OneSlotStore() + anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) + tx_store = AsyncMock() + tx_store.get.return_value = TransactionData(code_verifier="cv1", domain="auth0.local") + client = ServerClient( + domain="auth0.local", + client_id="cid", + client_secret="csecret", + transaction_store=tx_store, + state_store=AsyncMock(), + anonymous_store=anon_store, + secret=secret, + clear_anonymous_session_on_login=True, + ) + _setup_complete_interactive_login(client, mocker) + mocker.patch.object( + client._anonymous_client, "logout", AsyncMock(side_effect=Exception("store down")) + ) + + result = await client.complete_interactive_login("https://auth0.local/cb?code=c&state=s") + + assert "state_data" in result + + class _CapturingHttpClient: """Fake httpx.AsyncClient that records POST URLs and returns a fixed status.""" From 5e6be62092f4dce7bd5ba34eeec80243c082aa48 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 10:19:57 +0530 Subject: [PATCH 22/49] fix: use parsed-host comparisons in anonymous session URL test assertions --- .../tests/test_anonymous_client.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 26a302fc..5affc74b 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -6,6 +6,7 @@ import inspect import time from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlsplit import httpx import pytest @@ -729,8 +730,8 @@ async def test_second_audience_is_cached_without_evicting_first(self): stored = await store.get(ANON_IDENTIFIER) context = client._decrypt_context(stored) audiences = [ts.audience for ts in context.token_sets] - assert "https://api1.example.com" in audiences - assert "https://api2.example.com" in audiences + assert any(a == "https://api1.example.com" for a in audiences) + assert any(a == "https://api2.example.com" for a in audiences) @pytest.mark.asyncio async def test_cached_token_returned_for_correct_audience(self): @@ -809,8 +810,8 @@ async def get_with_concurrent_write(identifier, *, options=None): stored = await store.get(ANON_IDENTIFIER) final_ctx = client._decrypt_context(stored) audiences = [ts.audience for ts in final_ctx.token_sets] - assert "https://api2.example.com" in audiences - assert "https://api1.example.com" in audiences + assert any(a == "https://api2.example.com" for a in audiences) + assert any(a == "https://api1.example.com" for a in audiences) @pytest.mark.asyncio async def test_remint_skips_write_when_session_deleted_during_fetch(self): @@ -944,7 +945,7 @@ async def test_domain_mismatch_in_resolver_mode_mints_fresh_under_current_tenant with patch("httpx.AsyncClient", fake_http): await client.get_token() _, url, _ = fake_http.calls[0] - assert url.startswith("https://tenant-b.auth0.local") + assert urlsplit(url).hostname == "tenant-b.auth0.local" @pytest.mark.asyncio async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(self): @@ -957,7 +958,7 @@ async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(s with patch("httpx.AsyncClient", fake_http): await client.get_token() _, url, _ = fake_http.calls[0] - assert "tenant-a.auth0.local" not in url + assert urlsplit(url).hostname != "tenant-a.auth0.local" @pytest.mark.asyncio async def test_domain_mismatch_remint_preserves_metadata(self): @@ -977,7 +978,7 @@ async def test_domain_mismatch_remint_preserves_metadata(self): with patch("httpx.AsyncClient", fake_http): await client.get_token() _, url, kwargs = fake_http.calls[0] - assert url.startswith("https://tenant-b.auth0.local") + assert urlsplit(url).hostname == "tenant-b.auth0.local" assert kwargs["json"]["metadata"] == {"cart": ["sku-1"]} @pytest.mark.asyncio @@ -1137,7 +1138,7 @@ async def test_missing_context_domain_mints_against_origin(self): ticket = await client.exchange_transfer_token_for_injection("auth0.local") assert ticket == "TICKET" _, url, _ = fake_http.calls[0] - assert url.startswith("https://auth0.local") + assert urlsplit(url).hostname == "auth0.local" @pytest.mark.asyncio async def test_returns_none_without_store(self): From d101cec0cd61813f7ec9b2fa8bfa7f5ca9db2308 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 15:29:06 +0530 Subject: [PATCH 23/49] refactor: move _end_session_if_active out of AnonymousClient public API section --- .../auth_server/anonymous_client.py | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 47795527..89b10d6e 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -820,27 +820,6 @@ async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: return await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) - async def _end_session_if_active( - self, store_options: Optional[dict[str, Any]] = None - ) -> None: - """Clear the local anonymous session on authenticated logout, if one is active. - - Args: - store_options: Options passed to the anonymous store. - """ - if self._anonymous_store is None: - return - try: - stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) - except Exception: - return - if not stored: - return - try: - await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) - except Exception: - return - async def get_session( self, store_options: Optional[dict[str, Any]] = None ) -> Optional[AnonymousSessionData]: @@ -880,3 +859,24 @@ async def get_session( session_expires_at=context.session_expires_at, domain=context.domain, ) + + async def _end_session_if_active( + self, store_options: Optional[dict[str, Any]] = None + ) -> None: + """Clear the local anonymous session on authenticated logout, if one is active. + + Args: + store_options: Options passed to the anonymous store. + """ + if self._anonymous_store is None: + return + try: + stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) + except Exception: + return + if not stored: + return + try: + await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) + except Exception: + return From 19859718b580b6e1708119aaf9c1b72e8f0664cc Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 15:29:12 +0530 Subject: [PATCH 24/49] docs: fix stale logout/login-injection claims and trim anonymous sessions guide --- examples/AnonymousSessions.md | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index b1eb5e99..a1c40472 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -17,12 +17,11 @@ Anonymous Sessions give a visitor an Auth0 identity **before they log in**. Each - [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. +Before using the anonymous sessions API, the `anonymous_sessions_enabled` flag must be turned on for your tenant, and the application/client must be enabled for the feature. Pass an `anonymous_store` to `ServerClient`, alongside your existing `state_store` and `transaction_store`: @@ -85,15 +84,13 @@ 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. - Authenticated (OIDC) logout also ends an active anonymous session. When you call `ServerClient.logout()` and an anonymous store is configured, the SDK clears the locally-held anonymous session before returning the logout URL. This is a local clear only, with no remote call (consistent with `anonymous.logout()`, which also does not revoke server-side). It prevents the next visitor on a shared device from having the previous visitor's anonymous identity re-linked at their login. If no anonymous session is active, nothing is cleared, and any failure ending the anonymous session is best-effort and never breaks the authenticated logout. ## Login Injection -When an anonymous session is active, `start_interactive_login()` automatically links it to the login, no code change needed at your call site. If no anonymous session exists, behavior is the same as today. +When an anonymous session is active, `start_interactive_login()` automatically injects an `anon_transfer_token` transfer ticket into the `/authorize` URL, no code change needed at your call site. If no anonymous session exists, behavior is unchanged. -The raw session token never goes on the URL. At the moment the `/authorize` URL is built, the SDK exchanges the stored session token for a short-lived (30s) transfer ticket (`anon_transfer_token`) via `POST /anonymous/token`, and forwards only that ticket as the `anon_transfer_token` query parameter. The raw session token stays inside the SDK's encrypted store and the ticket is never persisted. The ticket is short-lived and grants no authorization on its own, but you should still set `Referrer-Policy: no-referrer` on your login pages and never log the authorize URL. +The raw `session_token` never goes on the URL. At the moment the `/authorize` URL is built, the SDK exchanges the stored session token for a short-lived (30s) single-use ticket (`anon_transfer_token`) via `POST /anonymous/token`, and forwards only that ticket as the `anon_transfer_token` query parameter. The raw session token stays inside the SDK's encrypted store and the ticket is never persisted. The ticket is short-lived and grants no authorization on its own, but you should still set `Referrer-Policy: no-referrer` on your login pages and never log the authorize URL. The exchange fails open: if it errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. Under Multiple Custom Domains it fails closed: a ticket is only minted for the domain the session was created against, so a domain mismatch mints nothing (see [MultipleCustomDomains.md](MultipleCustomDomains.md)). @@ -117,7 +114,6 @@ from auth0_server_python.error import ( AnonymousSessionCreateError, AnonymousSessionTokenError, AnonymousSessionIntrospectError, - AnonymousSessionLogoutError, ) try: @@ -125,9 +121,3 @@ try: 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 `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 From 11ec44d241c91efccd80261d1793c411e8742196 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 16:18:45 +0530 Subject: [PATCH 25/49] refactor: fix docstring format and move anonymous property to end of ServerClient --- .../auth_server/anonymous_client.py | 8 ++++++-- .../auth_server/server_client.py | 18 +++++++++--------- src/auth0_server_python/auth_types/__init__.py | 6 +----- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 89b10d6e..c2b51518 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -164,8 +164,12 @@ def _normalize_url(value: Optional[str]) -> Optional[str]: def _decode_sub(access_token: str) -> Optional[str]: """Extract the sub claim from a JWT access token without verifying the signature. - Returns None for opaque tokens (JWE has 5 dot-separated parts), non-JWT - strings, or tokens that carry no sub claim. + Args: + access_token: The token to decode. + + Returns: + The sub claim, or None for opaque tokens, non-JWT strings, or + tokens with no sub claim. """ try: parts = access_token.split(".") diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 04894f7a..0e32e077 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -3496,15 +3496,6 @@ def mfa(self) -> MfaClient: ) 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 # ============================================================================ @@ -4014,6 +4005,15 @@ async def start_enterprise_login( ) return await self.start_interactive_login(login_options, store_options) + # ============================================================================ + # ANONYMOUS SESSIONS + # ============================================================================ + + @property + def anonymous(self) -> AnonymousClient: + """Access the anonymous sessions client for pre-login anon@ identity operations.""" + return self._anonymous_client + async def is_federated_domain(domain: str, email_domain: str, timeout: float = 5.0) -> bool: """ diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 34d4bed4..67166058 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -897,11 +897,7 @@ class AnonymousSession(BaseModel): class AnonymousSessionData(BaseModel): - """Local session state returned by AnonymousClient.get_session(). - - Contains identity and metadata fields only. session_token is never - included; use get_token() when a bearer token is needed. - """ + """Identity and metadata fields returned by AnonymousClient.get_session(), with no session_token.""" sub: Optional[str] = None metadata: Optional[dict[str, Any]] = None From 8d26ea21d00b61f365ac41619e2a238a1bfbf119 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 18:35:51 +0530 Subject: [PATCH 26/49] docs: remove dashes, semicolon splices, and narrating comments per repo conventions --- README.md | 2 +- examples/AnonymousSessions.md | 14 ++++++------ examples/MultipleCustomDomains.md | 2 +- references/flow-map.md | 2 +- .../auth_server/anonymous_client.py | 2 +- .../auth_types/__init__.py | 5 +---- src/auth0_server_python/tests/store_fakes.py | 9 +------- .../tests/test_anonymous_client.py | 2 +- .../tests/test_server_client.py | 22 +++++-------------- 9 files changed, 19 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 0ed58f2d..db0b789a 100644 --- a/README.md +++ b/README.md @@ -258,7 +258,7 @@ Sign users in with a one-time code sent by email or SMS, or with a magic link se ### 11. 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). +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). ### 12. Enterprise Connect (Embedded Login) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index a1c40472..4768a205 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -1,9 +1,9 @@ # 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 SDK carries the session to Auth0 as a short-lived transfer ticket 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. +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 SDK carries the session to Auth0 as a short-lived transfer ticket 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. +> 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 @@ -50,7 +50,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. 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. +`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` returns `session_token`, `access_token`, `expires_at`, `session_expires_at`, and `metadata`. @@ -62,10 +62,10 @@ 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 `session_token` 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. +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, since 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 `session_token` 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 diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index 3fda1eec..aa35e43d 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -325,7 +325,7 @@ All domain mismatch errors use the message: **"Session domain does not match the ### Anonymous-session linking -Anonymous-session login linking is domain-bound and fails closed under MCD. When `start_interactive_login()` builds the `/authorize` URL, the SDK mints the anonymous transfer ticket (`anon_transfer_token`) only when the anonymous session's stored domain matches the resolved login domain. On a domain mismatch the SDK mints no ticket, so the anonymous session is never carried across custom domains. Linking then simply does not happen for that login; the login itself proceeds normally (fail-open on the linking, fail-closed on the domain). +Anonymous-session login linking is domain-bound and fails closed under MCD. When `start_interactive_login()` builds the `/authorize` URL, the SDK mints the anonymous transfer ticket (`anon_transfer_token`) only when the anonymous session's stored domain matches the resolved login domain. On a domain mismatch the SDK mints no ticket, so the anonymous session is never carried across custom domains. Linking then simply does not happen for that login, and the login itself proceeds normally (fail-open on the linking, fail-closed on the domain). ## Legacy Sessions and Migration diff --git a/references/flow-map.md b/references/flow-map.md index 0d6adfac..f7789ca1 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -19,7 +19,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl | MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` | | Enterprise Connect | `start_enterprise_login`, `complete_interactive_login` (EC branch), `is_federated_domain` (standalone), `logout` (`federated`) | `auth_types/` (`StartEnterpriseLoginOptions`, `LogoutOptions.federated`), `error/` (`EnterpriseConnectError`); the SDK owns no session in this mode | `examples/EnterpriseConnect.md` | | mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify`, `token_endpoint_resolver`) | `examples/MutualTLS.md` | -| Anonymous sessions | `ServerClient.anonymous` (property, returns `AnonymousClient`): `create_session`, `get_token`, `get_session`, `logout`, `introspect`; `start_interactive_login` injects `session_token` via `AnonymousClient.get_session_token_for_injection` | `auth_server/anonymous_client.py`, `encryption/encrypt.py` (context encrypted at rest), `store/abstract.py` (requires a dedicated `anonymous_store` instance; shares the `StateStore` ABC) | `examples/AnonymousSessions.md` | +| Anonymous sessions | `ServerClient.anonymous` (property, returns `AnonymousClient`): `create_session`, `get_token`, `get_session`, `logout`, `introspect`. `start_interactive_login` injects an `anon_transfer_token` via `AnonymousClient.exchange_transfer_token_for_injection` | `auth_server/anonymous_client.py`, `encryption/encrypt.py` (context encrypted at rest), `store/abstract.py` (requires a dedicated `anonymous_store` instance, sharing the `StateStore` ABC) | `examples/AnonymousSessions.md` | Two rules cut across every flow above, so check them on any change here: resolve the domain through `await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index c2b51518..519c7dd7 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -587,7 +587,7 @@ async def exchange_transfer_token_for_injection( context = self._decrypt_context(stored) except _AnonymousSessionExpired: return None - # Domain mismatch rejects a session belonging to a different tenant. + # Prevents a tenant-A session token from minting a transfer ticket usable at tenant-B's login. if context.domain and self._normalize_url(context.domain) != self._normalize_url( origin_domain ): diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 67166058..b842a344 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -953,10 +953,7 @@ class AnonymousTokenSetEntry(BaseModel): class AnonymousSessionContext(BaseModel): - """Internal context stored inside the encrypted anonymous session record. - - Rejects extra fields so a tampered payload fails closed on decrypt. - """ + """Internal context stored inside the encrypted anonymous session record, rejecting extra fields so a tampered payload fails closed on decrypt.""" model_config = ConfigDict(extra="forbid") diff --git a/src/auth0_server_python/tests/store_fakes.py b/src/auth0_server_python/tests/store_fakes.py index b06a7004..b4cd895e 100644 --- a/src/auth0_server_python/tests/store_fakes.py +++ b/src/auth0_server_python/tests/store_fakes.py @@ -2,14 +2,7 @@ class OneSlotStore: - """Models a StatelessStateStore where the store identifier is an - encryption salt, not a location key. - - One physical slot per instance, so a mismatched identifier reads as - absent, not as a different record. AsyncMock cannot exercise this - collision because it treats every identifier as a distinct key, so this - fake is required instead. - """ + """Models a StatelessStateStore with one physical slot per instance, where the identifier is an encryption salt rather than a location key, so a mismatched identifier reads as absent rather than a different record.""" def __init__(self): self.slot = None diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 5affc74b..25306563 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -1129,7 +1129,7 @@ async def test_success_returns_ticket_with_correct_request_body(self): @pytest.mark.asyncio async def test_missing_context_domain_mints_against_origin(self): - """A stored context with no domain is not an MCD mismatch; mint against origin.""" + """A stored context with no domain is not an MCD mismatch, so mint against origin.""" store = OneSlotStore() _stored_context(store, session_token="REAL_TOKEN") # domain defaults to None 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 01bffc86..71fbc736 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -10035,7 +10035,7 @@ def fake_create_url(endpoint, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_does_not_persist_transfer_token_in_transaction_data(mocker): - """The short-lived ticket rides the URL only; it is never written to the transaction record.""" + """The short-lived ticket rides the URL only and is never written to the transaction record.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10191,12 +10191,7 @@ async def post(self, url, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_constructor_fixation_blocked_no_active_session(): - """ - 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. - """ + """A caller-supplied session_token in constructor authorization_params is blocked by the unconditional pop() at the injection site, since INTERNAL_AUTHORIZE_PARAMS alone only filters per-call options.""" assert "session_token" in INTERNAL_AUTHORIZE_PARAMS # belt-and-braces still present anon_store = OneSlotStore() # no session -> the vulnerable case @@ -10545,7 +10540,7 @@ async def test_logout_ends_active_anonymous_session(mocker): @pytest.mark.asyncio async def test_logout_no_anonymous_session_makes_no_remote_call(mocker): """With no active anonymous session, authenticated logout makes no anonymous remote call.""" - anon_store = OneSlotStore() # empty + anon_store = OneSlotStore() client = ServerClient( domain="auth0.local", client_id="cid", @@ -10616,11 +10611,7 @@ async def test_logout_survives_anonymous_session_cleanup_failure(mocker): @pytest.mark.asyncio async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_store(): - """ - 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. - """ + """A write to the anonymous store instance never touches the authenticated session on a separate store instance.""" shared_store = OneSlotStore() shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) @@ -10646,10 +10637,7 @@ async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_st @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. - """ + """A missing anonymous_store raises before any write, never silently falling back to state_store.""" shared_store = OneSlotStore() shared_store.slot = ("_a0_session", {"user": {"sub": "real_user"}}) client = ServerClient( From dce5efaf963008be34919bc587d237bd2afda1be Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 24 Sep 2026 19:05:52 +0530 Subject: [PATCH 27/49] fix: remove introspect() and its unimplemented /anonymous/userinfo endpoint --- examples/AnonymousSessions.md | 23 +----- examples/MultipleCustomDomains.md | 4 - references/flow-map.md | 2 +- .../auth_server/anonymous_client.py | 63 +--------------- .../auth_types/__init__.py | 10 --- src/auth0_server_python/error/__init__.py | 7 -- .../tests/test_anonymous_client.py | 73 ------------------- 7 files changed, 6 insertions(+), 176 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 4768a205..9af7a437 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -1,9 +1,6 @@ # 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 SDK carries the session to Auth0 as a short-lived transfer ticket 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. +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 attached at creation. At login, the SDK carries the session to Auth0 as a short-lived transfer ticket 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. ## Table of Contents @@ -12,7 +9,6 @@ Anonymous Sessions give a visitor an Auth0 identity **before they log in**. Each - [Setup](#setup) - [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) @@ -50,7 +46,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. 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. +`metadata` is **set once, at creation, and never updated**. 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` returns `session_token`, `access_token`, `expires_at`, `session_expires_at`, and `metadata`. @@ -67,14 +63,6 @@ Renewal logic, in order: 3. Session token also expired or invalid, a **brand-new session is silently created, once**. Metadata from the old session is permanently lost, and `session_token` 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) -``` - -`introspect()` is read-only: it returns the current session status without renewing the token or changing `sub`. - ## Logging Out ```python @@ -84,7 +72,7 @@ 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. -Authenticated (OIDC) logout also ends an active anonymous session. When you call `ServerClient.logout()` and an anonymous store is configured, the SDK clears the locally-held anonymous session before returning the logout URL. This is a local clear only, with no remote call (consistent with `anonymous.logout()`, which also does not revoke server-side). It prevents the next visitor on a shared device from having the previous visitor's anonymous identity re-linked at their login. If no anonymous session is active, nothing is cleared, and any failure ending the anonymous session is best-effort and never breaks the authenticated logout. +Authenticated (OIDC) logout also ends an active anonymous session. When you call `ServerClient.logout()` and an anonymous store is configured, the SDK clears the locally-held anonymous session before returning the logout URL. This is a local clear only, with no remote call (consistent with `anonymous.logout()`, which also does not revoke server-side). It prevents the next visitor on a shared device from having the previous visitor's anonymous identity re-linked at their login. If no anonymous session is active, nothing is cleared. ## Login Injection @@ -92,9 +80,7 @@ When an anonymous session is active, `start_interactive_login()` automatically i The raw `session_token` never goes on the URL. At the moment the `/authorize` URL is built, the SDK exchanges the stored session token for a short-lived (30s) single-use ticket (`anon_transfer_token`) via `POST /anonymous/token`, and forwards only that ticket as the `anon_transfer_token` query parameter. The raw session token stays inside the SDK's encrypted store and the ticket is never persisted. The ticket is short-lived and grants no authorization on its own, but you should still set `Referrer-Policy: no-referrer` on your login pages and never log the authorize URL. -The exchange fails open: if it errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. Under Multiple Custom Domains it fails closed: a ticket is only minted for the domain the session was created against, so a domain mismatch mints nothing (see [MultipleCustomDomains.md](MultipleCustomDomains.md)). - -Pushed Authorization Requests (PAR) and Enterprise Connect are not supported for anonymous-session linking, so injection is suppressed entirely on those code paths. +The exchange fails open: if it errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. ## Rate-Limiting `get_token()` @@ -113,7 +99,6 @@ from auth0_server_python.error import ( AnonymousSessionScopeError, AnonymousSessionCreateError, AnonymousSessionTokenError, - AnonymousSessionIntrospectError, ) try: diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index aa35e43d..ec6c68be 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -323,10 +323,6 @@ All domain mismatch errors use the message: **"Session domain does not match the > **Note:** If a login was started before the switch to resolver mode and completes after, the SDK falls back to the current resolved domain for token exchange. The resulting session will store the resolved domain and work normally going forward. -### Anonymous-session linking - -Anonymous-session login linking is domain-bound and fails closed under MCD. When `start_interactive_login()` builds the `/authorize` URL, the SDK mints the anonymous transfer ticket (`anon_transfer_token`) only when the anonymous session's stored domain matches the resolved login domain. On a domain mismatch the SDK mints no ticket, so the anonymous session is never carried across custom domains. Linking then simply does not happen for that login, and the login itself proceeds normally (fail-open on the linking, fail-closed on the domain). - ## Legacy Sessions and Migration When moving from a static domain setup to resolver mode, existing sessions can continue diff --git a/references/flow-map.md b/references/flow-map.md index f7789ca1..ea43e35a 100644 --- a/references/flow-map.md +++ b/references/flow-map.md @@ -19,7 +19,7 @@ Before working on a flow, read its entry points and supporting modules. Every fl | MCD | any flow — `domain` may be an async resolver | `_resolve_current_domain`, pitfall 5 in `references/pitfalls.md` | `examples/MultipleCustomDomains.md` | | Enterprise Connect | `start_enterprise_login`, `complete_interactive_login` (EC branch), `is_federated_domain` (standalone), `logout` (`federated`) | `auth_types/` (`StartEnterpriseLoginOptions`, `LogoutOptions.federated`), `error/` (`EnterpriseConnectError`); the SDK owns no session in this mode | `examples/EnterpriseConnect.md` | | mTLS client auth | constructor `use_mtls` + `ssl_context` | `_resolve_token_endpoint`, `_apply_client_authentication`, `_warn_if_not_cert_bound`, `mfa_client.py` (`use_mtls`, `ssl_context`, `verify`, `token_endpoint_resolver`) | `examples/MutualTLS.md` | -| Anonymous sessions | `ServerClient.anonymous` (property, returns `AnonymousClient`): `create_session`, `get_token`, `get_session`, `logout`, `introspect`. `start_interactive_login` injects an `anon_transfer_token` via `AnonymousClient.exchange_transfer_token_for_injection` | `auth_server/anonymous_client.py`, `encryption/encrypt.py` (context encrypted at rest), `store/abstract.py` (requires a dedicated `anonymous_store` instance, sharing the `StateStore` ABC) | `examples/AnonymousSessions.md` | +| Anonymous sessions | `ServerClient.anonymous` (property, returns `AnonymousClient`): `create_session`, `get_token`, `get_session`, `logout`. `start_interactive_login` injects an `anon_transfer_token` via `AnonymousClient.exchange_transfer_token_for_injection` | `auth_server/anonymous_client.py`, `encryption/encrypt.py` (context encrypted at rest), `store/abstract.py` (requires a dedicated `anonymous_store` instance, sharing the `StateStore` ABC) | `examples/AnonymousSessions.md` | Two rules cut across every flow above, so check them on any change here: resolve the domain through `await self._resolve_current_domain(store_options)` rather than reading `self._domain`, and accept diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 519c7dd7..cf93d534 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -11,13 +11,11 @@ import httpx from pydantic import ValidationError -from auth0_server_python.auth_schemes.bearer_auth import BearerAuth from auth0_server_python.auth_types import ( AnonymousCreateTokenResponse, AnonymousSession, AnonymousSessionContext, AnonymousSessionData, - AnonymousSessionIntrospection, AnonymousTokenResponse, AnonymousTokenSetEntry, AnonymousTransferTokenResponse, @@ -30,7 +28,6 @@ AnonymousSessionClientNotSupportedError, AnonymousSessionCreateError, AnonymousSessionFeatureNotEnabledError, - AnonymousSessionIntrospectError, AnonymousSessionResourceServerError, AnonymousSessionScopeError, AnonymousSessionTokenError, @@ -241,7 +238,7 @@ def _map_anonymous_error( Args: status_code: The HTTP status code of the response. error_data: The parsed error response body. - operation: One of 'create', 'token', 'introspect'. + operation: One of 'create', 'token'. Returns: The exception instance. Does not raise it. @@ -266,8 +263,6 @@ def _map_anonymous_error( return AnonymousSessionCreateError(description, cause=error_data) if operation == "token": return AnonymousSessionTokenError(description, error_data) - if operation == "introspect": - return AnonymousSessionIntrospectError(description, error_data) return AnonymousSessionApiError(code or "anonymous_error", description, error_data) # ============================================================================ @@ -753,62 +748,6 @@ async def get_token( return await self._remint(context, eff_audience, eff_scope, store_options) - async def introspect( - self, store_options: Optional[dict[str, Any]] = None - ) -> AnonymousSessionIntrospection: - """Return the current anonymous session status without mutating the store. - - Args: - store_options: Options passed to the anonymous store. - - Returns: - The current AnonymousSessionIntrospection. - - 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) - 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.session_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 - async def logout(self, store_options: Optional[dict[str, Any]] = None) -> None: """Clear the locally-held anonymous session without revoking issued tokens. diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index b842a344..c48da667 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -906,16 +906,6 @@ class AnonymousSessionData(BaseModel): domain: Optional[str] = None -class AnonymousSessionIntrospection(BaseModel): - """Result of introspect(), lenient to unrecognized response fields.""" - - 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.""" diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 198217ed..8e5e014e 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -465,13 +465,6 @@ def __init__(self, message: str, cause: Optional[dict] = None): super().__init__("anonymous_token_error", message, cause) -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 AnonymousSessionFeatureNotEnabledError(AnonymousSessionCreateError): """Error thrown when the tenant has not enabled the anonymous sessions add-on.""" diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 25306563..1a62b83d 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -27,7 +27,6 @@ AnonymousSessionClientNotSupportedError, AnonymousSessionCreateError, AnonymousSessionFeatureNotEnabledError, - AnonymousSessionIntrospectError, AnonymousSessionResourceServerError, AnonymousSessionScopeError, AnonymousSessionTokenError, @@ -170,12 +169,6 @@ async def test_get_token_without_store_raises_configuration_error(self): 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) @@ -992,72 +985,6 @@ async def test_domain_resolver_failure_propagates(self): 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.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() - - @pytest.mark.asyncio - async def test_introspect_corrupted_context_raises_without_server_call(self): - store = OneSlotStore() - store.slot = (ANON_IDENTIFIER, {"context": "not-a-decryptable-blob"}) - client = _make_client(anonymous_store=store) - with patch("httpx.AsyncClient") as mock_http: - with pytest.raises(AnonymousSessionIntrospectError): - await client.introspect() - mock_http.assert_not_called() - - # ── logout ──────────────────────────────────────────────────────────────────── class TestLogout: From bc59c3bdf3d0a191da852103b57f997b6e4b59b4 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 25 Sep 2026 10:06:52 +0530 Subject: [PATCH 28/49] feat: default clear_anonymous_session_on_login to True --- src/auth0_server_python/auth_server/server_client.py | 7 ++++--- src/auth0_server_python/tests/test_server_client.py | 10 +++++----- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 0e32e077..70ce98af 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -167,7 +167,7 @@ def __init__( enterprise_connect: bool = False, use_mtls: bool = False, ssl_context: Optional[ssl.SSLContext] = None, - clear_anonymous_session_on_login: bool = False, + clear_anonymous_session_on_login: bool = True, ): """ Initialize the Auth0 server client. @@ -212,8 +212,9 @@ def __init__( clear_anonymous_session_on_login: When True and an anonymous_store is configured, the anonymous session is cleared via anonymous.logout() after a successful interactive login (complete_interactive_login). - Defaults to False. The logout call is best-effort and never fails - an otherwise-successful login. + Defaults to True. Set to False to keep the anonymous session active + across the login boundary. The logout call is best-effort and never + fails an otherwise-successful login. Raises: ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds. diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 71fbc736..a8073431 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -10404,8 +10404,8 @@ def _setup_complete_interactive_login(client, mocker): @pytest.mark.asyncio -async def test_complete_interactive_login_does_not_clear_anonymous_session_by_default(mocker): - """Default (clear_anonymous_session_on_login=False): anonymous session is not touched on login.""" +async def test_complete_interactive_login_does_not_clear_anonymous_session_when_disabled(mocker): + """clear_anonymous_session_on_login=False: anonymous session is not touched on login.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10419,6 +10419,7 @@ async def test_complete_interactive_login_does_not_clear_anonymous_session_by_de state_store=AsyncMock(), anonymous_store=anon_store, secret=secret, + clear_anonymous_session_on_login=False, ) _setup_complete_interactive_login(client, mocker) logout_spy = mocker.patch.object(client._anonymous_client, "logout", AsyncMock()) @@ -10430,8 +10431,8 @@ async def test_complete_interactive_login_does_not_clear_anonymous_session_by_de @pytest.mark.asyncio -async def test_complete_interactive_login_clears_anonymous_session_when_enabled(mocker): - """clear_anonymous_session_on_login=True: anonymous.logout() is called after session is written.""" +async def test_complete_interactive_login_clears_anonymous_session_by_default(mocker): + """Default (clear_anonymous_session_on_login=True): anonymous.logout() is called after session is written.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10445,7 +10446,6 @@ async def test_complete_interactive_login_clears_anonymous_session_when_enabled( state_store=AsyncMock(), anonymous_store=anon_store, secret=secret, - clear_anonymous_session_on_login=True, ) _setup_complete_interactive_login(client, mocker) logout_spy = mocker.patch.object(client._anonymous_client, "logout", AsyncMock()) From 86f8f7a0c48802c2796fd2d3ae22b5f7d35c84ae Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 25 Sep 2026 10:09:13 +0530 Subject: [PATCH 29/49] docs: remove fail-open/fail-closed/best-effort labels and comment colons --- examples/AnonymousSessions.md | 2 +- .../auth_server/anonymous_client.py | 10 ++++------ src/auth0_server_python/auth_server/server_client.py | 7 +++---- src/auth0_server_python/auth_types/__init__.py | 2 +- .../tests/test_anonymous_client.py | 12 ++++++------ src/auth0_server_python/tests/test_server_client.py | 12 ++++++------ 6 files changed, 21 insertions(+), 24 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index 9af7a437..e31e8666 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -80,7 +80,7 @@ When an anonymous session is active, `start_interactive_login()` automatically i The raw `session_token` never goes on the URL. At the moment the `/authorize` URL is built, the SDK exchanges the stored session token for a short-lived (30s) single-use ticket (`anon_transfer_token`) via `POST /anonymous/token`, and forwards only that ticket as the `anon_transfer_token` query parameter. The raw session token stays inside the SDK's encrypted store and the ticket is never persisted. The ticket is short-lived and grants no authorization on its own, but you should still set `Referrer-Policy: no-referrer` on your login pages and never log the authorize URL. -The exchange fails open: if it errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. +If the exchange errors (network failure, a non-200, or an unparseable response), login proceeds with no ticket and no linking, and never aborts the login. ## Rate-Limiting `get_token()` diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index cf93d534..be759d7e 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -43,8 +43,7 @@ ANON_IDENTIFIER = "_a0_anon" ANON_TOKEN_SALT = "anon_session" -# Audience for the /anonymous/token exchange that mints a short-lived transfer -# ticket for login injection. +# Audience that mints the login-injection transfer ticket instead of an access token. TRANSFER_AUDIENCE = "urn:auth0:anon_transfer" _METADATA_MAX_BYTES = 1024 @@ -92,7 +91,7 @@ def _get_http_client(self, **kwargs) -> httpx.AsyncClient: return httpx.AsyncClient(headers=headers, **kwargs) def _require_store(self) -> None: - """Fail closed when no anonymous store is configured. + """Raise ConfigurationError when no anonymous_store is configured. Raises: ConfigurationError: No anonymous_store configured. @@ -525,8 +524,7 @@ async def _remint( sub=new_sub if new_sub is not None else context.sub, ) - # Re-read before writing back so concurrent remints for other audiences - # are preserved, and writes to a deleted or replaced session are skipped. + # Re-read to preserve a concurrent remint and skip a stale write. current_stored = await self._anonymous_store.get(ANON_IDENTIFIER, options=store_options) if not current_stored: return result @@ -592,7 +590,7 @@ async def exchange_transfer_token_for_injection( async def _mint_transfer_token( self, session_token: str, origin_domain: str ) -> Optional[str]: - """Exchange a session token for a transfer ticket. Fails open. + """Exchange a session token for a transfer ticket. Args: session_token: The stored anonymous session token. diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 70ce98af..231796a5 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -213,8 +213,8 @@ def __init__( configured, the anonymous session is cleared via anonymous.logout() after a successful interactive login (complete_interactive_login). Defaults to True. Set to False to keep the anonymous session active - across the login boundary. The logout call is best-effort and never - fails an otherwise-successful login. + across the login boundary. A failure clearing the anonymous session + never fails the login. Raises: ConfigurationError: If `mfa_token_ttl` is not a positive number of seconds. @@ -1477,8 +1477,7 @@ async def logout( if session_domain and self._normalize_url(session_domain) == self._normalize_url(domain): await self._state_store.delete(self._state_identifier, store_options) - # End any active anonymous session on authenticated logout to close the - # shared-device re-injection path. Failures are swallowed. + # Closes the shared-device re-injection path. if self._anonymous_store is not None: try: await self._anonymous_client._end_session_if_active(store_options) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index c48da667..356dd346 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -237,7 +237,7 @@ class LogoutOptions(BaseModel): class CreateAnonymousSessionOptions(BaseModel): - """Options bundle for create_session(): audience, scope, and metadata.""" + """Options bundle for create_session(), carrying audience, scope, and metadata.""" model_config = ConfigDict(extra="forbid") diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 1a62b83d..e3da32cb 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -61,7 +61,7 @@ def _fake_response(status_code=200, body=None): class _FakeAsyncClient: - """Patches httpx.AsyncClient. Call sequence maps 1:1 to responses.""" + """Patches httpx.AsyncClient. The call sequence maps one-to-one to responses.""" def __init__(self, responses): self._responses = list(responses) @@ -148,13 +148,13 @@ def test_constructor_accepts_callable_domain(self): assert client._domain_resolver is resolver def test_no_dpop_key_parameter_exists(self): - """Structural guard: AnonymousClient has no dpop_key parameter anywhere.""" + """AnonymousClient has no dpop_key parameter anywhere in its public API.""" 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 ─────────────────────────────────────────────── +# ── Store isolation ──────────────────────────────────────────────────────────── class TestStoreIsolation: @pytest.mark.asyncio @@ -177,7 +177,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.""" + """No anonymous_store configured raises before any write and never falls back to another store.""" client = _make_client(anonymous_store=None) with patch("httpx.AsyncClient") as mock_http: with pytest.raises(ConfigurationError): @@ -631,7 +631,7 @@ async def test_silent_remint_drops_metadata(self): @pytest.mark.asyncio async def test_two_consecutive_session_expired_raises_not_loops(self): - """Retry-once bound: exactly 2 upstream POSTs, then raise.""" + """The retry-once bound allows exactly 2 upstream POSTs, then raises.""" store = OneSlotStore() _stored_context(store, expires_at=int(time.time()) - 10) client = _make_client(anonymous_store=store) @@ -1089,7 +1089,7 @@ async def test_returns_none_on_corrupted_context(self): @pytest.mark.asyncio async def test_domain_mismatch_fails_closed_no_mint(self): - """MCD fail-closed: never mint a ticket for a host the session was not created against.""" + """Under MCD, never mint a ticket for a host the session was not created against.""" store = OneSlotStore() _stored_context(store, domain="tenant-a.auth0.local") 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 a8073431..bcd5e16c 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -10103,7 +10103,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.""" + """An undecryptable stored token denies the link but never aborts the login.""" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": "not-a-valid-jwe"}) client = ServerClient( @@ -10298,7 +10298,7 @@ def fake_create_url(endpoint, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_suppresses_injection_on_enterprise_connect(mocker): - """Enterprise Connect does not support anonymous-session linking: no exchange, no param.""" + """Enterprise Connect skips anonymous-session linking entirely, no exchange and no param.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10338,7 +10338,7 @@ def fake_create_url(endpoint, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_fail_open_when_exchange_returns_none(mocker): - """A failed/absent exchange yields no param and still returns the login URL (fail-open).""" + """A failed/absent exchange yields no param and still returns the login URL.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10405,7 +10405,7 @@ def _setup_complete_interactive_login(client, mocker): @pytest.mark.asyncio async def test_complete_interactive_login_does_not_clear_anonymous_session_when_disabled(mocker): - """clear_anonymous_session_on_login=False: anonymous session is not touched on login.""" + """With clear_anonymous_session_on_login=False, the anonymous session is not touched on login.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10432,7 +10432,7 @@ async def test_complete_interactive_login_does_not_clear_anonymous_session_when_ @pytest.mark.asyncio async def test_complete_interactive_login_clears_anonymous_session_by_default(mocker): - """Default (clear_anonymous_session_on_login=True): anonymous.logout() is called after session is written.""" + """By default (clear_anonymous_session_on_login=True), anonymous.logout() is called after the session is written.""" secret = "a-test-secret-with-enough-length" anon_store = OneSlotStore() anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)}) @@ -10561,7 +10561,7 @@ async def test_logout_no_anonymous_session_makes_no_remote_call(mocker): @pytest.mark.asyncio async def test_logout_unchanged_without_anonymous_store(mocker): - """No anonymous_store configured: logout is unchanged and makes no anonymous remote call.""" + """With no anonymous_store configured, logout is unchanged and makes no anonymous remote call.""" mock_state_store = AsyncMock() client = ServerClient( domain="auth0.local", From e1e6c68e560b0a5456d481e93b981f5fe710ebb2 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 25 Sep 2026 12:52:09 +0530 Subject: [PATCH 30/49] refactor: trim two overlong test docstrings --- src/auth0_server_python/tests/store_fakes.py | 2 +- src/auth0_server_python/tests/test_server_client.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/tests/store_fakes.py b/src/auth0_server_python/tests/store_fakes.py index b4cd895e..c10e5cee 100644 --- a/src/auth0_server_python/tests/store_fakes.py +++ b/src/auth0_server_python/tests/store_fakes.py @@ -2,7 +2,7 @@ class OneSlotStore: - """Models a StatelessStateStore with one physical slot per instance, where the identifier is an encryption salt rather than a location key, so a mismatched identifier reads as absent rather than a different record.""" + """A StatelessStateStore fake with one physical slot per instance, where the identifier is an encryption salt rather than a location key.""" def __init__(self): self.slot = None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index bcd5e16c..c7ac3b47 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -10191,7 +10191,7 @@ async def post(self, url, **kwargs): @pytest.mark.asyncio async def test_start_interactive_login_constructor_fixation_blocked_no_active_session(): - """A caller-supplied session_token in constructor authorization_params is blocked by the unconditional pop() at the injection site, since INTERNAL_AUTHORIZE_PARAMS alone only filters per-call options.""" + """The unconditional pop() at the injection site blocks a session_token supplied via constructor authorization_params.""" assert "session_token" in INTERNAL_AUTHORIZE_PARAMS # belt-and-braces still present anon_store = OneSlotStore() # no session -> the vulnerable case From 3b4b672d2c718e36d2868feb75fc32ee3e51f66e Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 25 Sep 2026 12:53:00 +0530 Subject: [PATCH 31/49] refactor: align anonymous session error hierarchy with spec --- examples/AnonymousSessions.md | 12 ++--- .../auth_server/anonymous_client.py | 29 +++------- src/auth0_server_python/error/__init__.py | 54 +++---------------- .../tests/test_anonymous_client.py | 37 +++++-------- 4 files changed, 28 insertions(+), 104 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index e31e8666..bcfd98cf 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -88,21 +88,17 @@ If the exchange errors (network failure, a non-200, or an unparseable response), ## Error Handling -All anonymous session errors subclass `AnonymousSessionApiError`, carrying a `.code` you can branch on: +All anonymous session errors subclass `AnonymousSessionError`, carrying a `.code` you can branch on: ```python from auth0_server_python.error import ( - AnonymousSessionFeatureNotEnabledError, - AnonymousSessionClientNotEnabledError, - AnonymousSessionClientNotSupportedError, - AnonymousSessionResourceServerError, - AnonymousSessionScopeError, AnonymousSessionCreateError, AnonymousSessionTokenError, ) try: session = await server_client.anonymous.create_session(audience="...", scope="...") -except AnonymousSessionFeatureNotEnabledError: - ... +except AnonymousSessionCreateError as e: + if e.code == "feature_not_enabled": + ... ``` diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index be759d7e..6971bad7 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -23,13 +23,8 @@ ) from auth0_server_python.encryption.encrypt import decrypt, encrypt from auth0_server_python.error import ( - AnonymousSessionApiError, - AnonymousSessionClientNotEnabledError, - AnonymousSessionClientNotSupportedError, AnonymousSessionCreateError, - AnonymousSessionFeatureNotEnabledError, - AnonymousSessionResourceServerError, - AnonymousSessionScopeError, + AnonymousSessionError, AnonymousSessionTokenError, ConfigurationError, DomainResolverError, @@ -228,14 +223,12 @@ def _parse_anonymous_error_body(response: httpx.Response) -> dict[str, Any]: def _map_anonymous_error( self, - status_code: int, error_data: dict[str, Any], operation: str, ) -> 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'. @@ -247,22 +240,12 @@ 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 AnonymousSessionClientNotSupportedError(description, error_data) - if code == "feature_not_enabled": - return AnonymousSessionFeatureNotEnabledError(description, error_data) - if code == "unauthorized_client": - return AnonymousSessionClientNotEnabledError(description, error_data) - if code in ("invalid_target", "invalid_request"): - return AnonymousSessionResourceServerError(description, error_data) - if code == "invalid_scope": - return AnonymousSessionScopeError(description, error_data) if operation == "create": - return AnonymousSessionCreateError(description, cause=error_data) + return AnonymousSessionCreateError(description, code=code or "anonymous_create_error", cause=error_data) if operation == "token": - return AnonymousSessionTokenError(description, error_data) - return AnonymousSessionApiError(code or "anonymous_error", description, error_data) + return AnonymousSessionTokenError(description, code=code or "anonymous_token_error", cause=error_data) + return AnonymousSessionError(code or "anonymous_error", description, error_data) # ============================================================================ # METADATA VALIDATION @@ -386,7 +369,7 @@ async def _create_session_at( if response.status_code != 200: error_data = self._parse_anonymous_error_body(response) - mapped = self._map_anonymous_error(response.status_code, error_data, "create") + mapped = self._map_anonymous_error(error_data, "create") if isinstance(mapped, _AnonymousSessionExpired): # Internal-only type must never escape. raise AnonymousSessionCreateError(str(mapped)) @@ -485,7 +468,7 @@ async def _remint( if response.status_code != 200: error_data = self._parse_anonymous_error_body(response) - mapped = self._map_anonymous_error(response.status_code, error_data, "token") + mapped = self._map_anonymous_error(error_data, "token") if isinstance(mapped, _AnonymousSessionExpired): # One follow-up create call on expiry, never a loop. return await self._create_session_at( diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 8e5e014e..07250e6a 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -430,8 +430,8 @@ class PasskeyErrorCode: # Anonymous Session Error Classes # ============================================================================= -class AnonymousSessionApiError(Auth0Error): - """Base class for anonymous session API errors.""" +class AnonymousSessionError(Auth0Error): + """Base class for anonymous session errors.""" def __init__( self, @@ -444,60 +444,18 @@ def __init__( self.cause = cause -class AnonymousSessionCreateError(AnonymousSessionApiError): +class AnonymousSessionCreateError(AnonymousSessionError): """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 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 AnonymousSessionTokenError(AnonymousSessionApiError): +class AnonymousSessionTokenError(AnonymousSessionError): """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 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 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 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 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 AnonymousSessionScopeError(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) + def __init__(self, message: str, code: str = "anonymous_token_error", cause: Optional[dict] = None): + super().__init__(code, message, cause) class _AnonymousSessionExpired(Auth0Error): diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index e3da32cb..27476dab 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -23,12 +23,7 @@ ) from auth0_server_python.encryption.encrypt import encrypt from auth0_server_python.error import ( - AnonymousSessionClientNotEnabledError, - AnonymousSessionClientNotSupportedError, AnonymousSessionCreateError, - AnonymousSessionFeatureNotEnabledError, - AnonymousSessionResourceServerError, - AnonymousSessionScopeError, AnonymousSessionTokenError, ConfigurationError, DomainResolverError, @@ -379,8 +374,9 @@ 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(AnonymousSessionFeatureNotEnabledError): + with pytest.raises(AnonymousSessionCreateError) as exc: await client.create_session(audience="aud", scope="s") + assert exc.value.code == "feature_not_enabled" @pytest.mark.asyncio async def test_unauthorized_client_maps_to_typed_error(self): @@ -390,43 +386,33 @@ 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(AnonymousSessionClientNotEnabledError): - 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(AnonymousSessionClientNotSupportedError) as exc: + with pytest.raises(AnonymousSessionCreateError) as exc: await client.create_session(audience="aud", scope="s") - assert message in str(exc.value) + assert exc.value.code == "unauthorized_client" @pytest.mark.asyncio - async def test_invalid_target_maps_to_resource_server_error(self): + async def test_invalid_target_maps_to_typed_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(AnonymousSessionResourceServerError): + with pytest.raises(AnonymousSessionCreateError) as exc: await client.create_session(audience="aud", scope="s") + assert exc.value.code == "invalid_target" @pytest.mark.asyncio - async def test_invalid_scope_maps_to_scope_error(self): + async def test_invalid_scope_maps_to_typed_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(AnonymousSessionScopeError): + with pytest.raises(AnonymousSessionCreateError) as exc: await client.create_session(audience="aud", scope="s") + assert exc.value.code == "invalid_scope" @pytest.mark.asyncio async def test_network_failure_raises_create_error(self): @@ -653,8 +639,9 @@ 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(AnonymousSessionFeatureNotEnabledError): + with pytest.raises(AnonymousSessionTokenError) as exc: await client.get_token() + assert exc.value.code == "feature_not_enabled" assert len(fake_http.calls) == 1 @pytest.mark.asyncio From 84b2472b4b7311c4dbe09b927186a1f1f9638e70 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 25 Sep 2026 12:53:26 +0530 Subject: [PATCH 32/49] docs: remove remaining fail-closed label from AnonymousSessionContext docstring --- src/auth0_server_python/auth_types/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 356dd346..4dca0120 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -943,7 +943,7 @@ class AnonymousTokenSetEntry(BaseModel): class AnonymousSessionContext(BaseModel): - """Internal context stored inside the encrypted anonymous session record, rejecting extra fields so a tampered payload fails closed on decrypt.""" + """Internal context stored inside the encrypted anonymous session record, rejecting extra fields so a tampered payload raises on decrypt.""" model_config = ConfigDict(extra="forbid") From 6986c1eefdc08ea736884144dbb5c4a45731dd28 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 10:41:20 +0530 Subject: [PATCH 33/49] fix: raise on decrypt failure in get_token instead of silently re-creating Introduces _SessionDecryptError to distinguish local JWE/parse failures from platform-signaled session expiry. get_token now deletes the corrupted record and raises AnonymousSessionTokenError(code="invalid_session_state") rather than minting a new anonymous identity. Best-effort paths (_remint re-read guard, exchange_transfer_token_for_injection, get_session) continue returning None/falling back on decrypt failure, consistent with how auth0-auth-js only silently re-creates on session_expired and invalid_session_token platform codes. --- .../auth_server/anonymous_client.py | 25 +++++++------- src/auth0_server_python/error/__init__.py | 12 +++++++ .../tests/test_anonymous_client.py | 33 +++++++++++++++---- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 6971bad7..3747450d 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -29,6 +29,7 @@ ConfigurationError, DomainResolverError, _AnonymousSessionExpired, + _SessionDecryptError, ) from auth0_server_python.utils.helpers import ( build_domain_resolver_context, @@ -317,7 +318,7 @@ def _decrypt_context(self, stored: Any) -> AnonymousSessionContext: payload = decrypt(encrypted, self._secret, ANON_TOKEN_SALT) return AnonymousSessionContext.model_validate(payload) except Exception as e: - raise _AnonymousSessionExpired( + raise _SessionDecryptError( "Stored anonymous session token is invalid or corrupted." ) from e @@ -513,7 +514,7 @@ async def _remint( return result try: current_context = self._decrypt_context(current_stored) - except _AnonymousSessionExpired: + except (_AnonymousSessionExpired, _SessionDecryptError): current_context = context if current_context.session_token != context.session_token: return result @@ -561,7 +562,7 @@ async def exchange_transfer_token_for_injection( return None try: context = self._decrypt_context(stored) - except _AnonymousSessionExpired: + except (_AnonymousSessionExpired, _SessionDecryptError): return None # Prevents a tenant-A session token from minting a transfer ticket usable at tenant-B's login. if context.domain and self._normalize_url(context.domain) != self._normalize_url( @@ -693,15 +694,13 @@ async def get_token( try: context = self._decrypt_context(stored) - except _AnonymousSessionExpired: - domain = await self._resolve_domain(store_options) - return await self._create_session_at( - domain, - audience=eff_audience, - scope=eff_scope, - metadata=None, - store_options=store_options, - ) + except _SessionDecryptError as e: + await self._anonymous_store.delete(ANON_IDENTIFIER, options=store_options) + raise AnonymousSessionTokenError( + "The stored anonymous session could not be decrypted. " + "Call create_session() to start a new session.", + code="invalid_session_state", + ) from e current_domain = await self._resolve_domain(store_options) if context.domain and self._normalize_url(context.domain) != self._normalize_url( @@ -767,7 +766,7 @@ async def get_session( return None try: context = self._decrypt_context(stored) - except _AnonymousSessionExpired: + except (_AnonymousSessionExpired, _SessionDecryptError): return None if context.domain: try: diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index 07250e6a..b905ab39 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -465,6 +465,18 @@ def __init__(self, message: str = "The anonymous session token is expired or inv super().__init__(message) self.name = "_AnonymousSessionExpired" + +class _SessionDecryptError(Auth0Error): + """Internal-only signal that the stored session payload could not be decrypted or parsed. + + Distinct from _AnonymousSessionExpired (platform expiry) - this indicates local + JWE decryption failure, e.g. secret rotation or store corruption. + """ + + def __init__(self, message: str = "The stored anonymous session could not be decrypted."): + super().__init__(message) + self.name = "_SessionDecryptError" + # ============================================================================= # Enterprise Connect Error Classes # ============================================================================= diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 27476dab..0344ebd1 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -564,6 +564,28 @@ async def test_remint_omits_audience_and_scope_when_the_session_had_none(self): assert "audience" not in kwargs["json"] assert "scope" not in kwargs["json"] + @pytest.mark.asyncio + async def test_remint_never_sends_metadata_in_body(self): + """Platform 400s if metadata is included in a renewal request.""" + 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( + 200, + { + "access_token": "AT2", + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + }, + ) + ]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, _, kwargs = fake_http.calls[0] + assert "metadata" not in kwargs["json"] + @pytest.mark.asyncio async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_stale_context( self, @@ -645,15 +667,14 @@ async def test_other_error_code_raises_typed_error_no_retry(self): assert len(fake_http.calls) == 1 @pytest.mark.asyncio - async def test_corrupted_stored_token_triggers_silent_new_session(self): + async def test_corrupted_stored_token_raises_and_clears_store(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())]) - with patch("httpx.AsyncClient", fake_http): - session = await client.get_token() - assert session.access_token == "AT1" - assert len(fake_http.calls) == 1 + with pytest.raises(AnonymousSessionTokenError) as exc: + await client.get_token() + assert exc.value.code == "invalid_session_state" + assert store.slot is None @pytest.mark.asyncio async def test_network_error_during_renewal_not_misclassified_as_expiry(self): From b127b685390931ba1f0e906262da33cd25d37e57 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 10:59:44 +0530 Subject: [PATCH 34/49] fix: tolerate missing session_expires_in for legacy anonymous tokens Make AnonymousTokenResponse.session_expires_in optional (None default). On create, session_expires_at is None when the field is absent. On remint, the stored session_expires_at is preserved so legacy tokens do not lose their expiry ceiling across renewals. --- .../auth_server/anonymous_client.py | 6 ++--- .../auth_types/__init__.py | 2 +- .../tests/test_anonymous_client.py | 24 +++++++++++++++++++ 3 files changed, 28 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 3747450d..557d68d2 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -403,7 +403,7 @@ async def _create_session_at( context = AnonymousSessionContext( session_token=token_response.session_token, token_sets=[token_set], - session_expires_at=now + token_response.session_expires_in, + session_expires_at=now + token_response.session_expires_in if token_response.session_expires_in is not None else None, metadata=metadata, created_at=now, domain=domain, @@ -503,7 +503,7 @@ async def _remint( access_token=token_set.access_token, session_token=new_session_token, expires_at=token_set.expires_at, - session_expires_at=now + token_response.session_expires_in, + session_expires_at=now + token_response.session_expires_in if token_response.session_expires_in is not None else context.session_expires_at, metadata=context.metadata, sub=new_sub if new_sub is not None else context.sub, ) @@ -524,7 +524,7 @@ async def _remint( updated_context = self._upsert_token_set( current_context.model_copy(update={ "session_token": new_session_token, - "session_expires_at": now + token_response.session_expires_in, + "session_expires_at": now + token_response.session_expires_in if token_response.session_expires_in is not None else context.session_expires_at, **sub_update, }), token_set, diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 4dca0120..366043ef 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -912,7 +912,7 @@ class AnonymousTokenResponse(BaseModel): access_token: str token_type: str = "Bearer" expires_in: int - session_expires_in: int + session_expires_in: Optional[int] = None session_token: Optional[str] = None diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 0344ebd1..ce158b36 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -474,6 +474,17 @@ async def test_create_session_stores_none_sub_for_opaque_token(self): ctx = client._decrypt_context(stored) assert ctx.sub is None + @pytest.mark.asyncio + async def test_create_session_tolerates_missing_session_expires_in(self): + """Legacy platform tokens omit session_expires_in; session_expires_at must be None.""" + store = OneSlotStore() + client = _make_client(anonymous_store=store) + response = {k: v for k, v in _token_response().items() if k != "session_expires_in"} + fake_http = _FakeAsyncClient([_fake_response(200, response)]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session() + assert session.session_expires_at is None + # ── get_token (renewal ladder) ──────────────────────────────────────────────── @@ -586,6 +597,19 @@ async def test_remint_never_sends_metadata_in_body(self): _, _, kwargs = fake_http.calls[0] assert "metadata" not in kwargs["json"] + @pytest.mark.asyncio + async def test_remint_preserves_session_expires_at_when_platform_omits_session_expires_in(self): + """Legacy renewal responses omit session_expires_in; stored expiry must be kept.""" + stored_expiry = int(time.time()) + 86400 + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10, session_expires_at=stored_expiry) + client = _make_client(anonymous_store=store) + response = {k: v for k, v in _token_response(access_token="AT2").items() if k != "session_expires_in"} + fake_http = _FakeAsyncClient([_fake_response(200, response)]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.session_expires_at == stored_expiry + @pytest.mark.asyncio async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_stale_context( self, From d4d8f18e1b3273bb682eca8d96c2a6aa6b5270fe Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 11:31:28 +0530 Subject: [PATCH 35/49] feat: surface granted scope in AnonymousSession from platform token response --- .../auth_server/anonymous_client.py | 5 +++ .../auth_types/__init__.py | 3 ++ .../tests/test_anonymous_client.py | 36 ++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 557d68d2..7a43039e 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -399,6 +399,7 @@ async def _create_session_at( expires_at=now + token_response.expires_in, audience=audience, scope=scope, + granted_scope=token_response.scope, ) context = AnonymousSessionContext( session_token=token_response.session_token, @@ -421,6 +422,7 @@ async def _create_session_at( session_expires_at=context.session_expires_at, metadata=context.metadata, sub=context.sub, + scope=token_set.granted_scope, ) # ============================================================================ @@ -498,6 +500,7 @@ async def _remint( expires_at=now + token_response.expires_in, audience=audience, scope=scope, + granted_scope=token_response.scope, ) result = AnonymousSession( access_token=token_set.access_token, @@ -506,6 +509,7 @@ async def _remint( session_expires_at=now + token_response.session_expires_in if token_response.session_expires_in is not None else context.session_expires_at, metadata=context.metadata, sub=new_sub if new_sub is not None else context.sub, + scope=token_response.scope, ) # Re-read to preserve a concurrent remint and skip a stale write. @@ -724,6 +728,7 @@ async def get_token( session_expires_at=context.session_expires_at, metadata=context.metadata, sub=context.sub, + scope=token_set.granted_scope, ) return await self._remint(context, eff_audience, eff_scope, store_options) diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 366043ef..88f22d26 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -894,6 +894,7 @@ class AnonymousSession(BaseModel): session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None sub: Optional[str] = None + scope: Optional[str] = None class AnonymousSessionData(BaseModel): @@ -914,6 +915,7 @@ class AnonymousTokenResponse(BaseModel): expires_in: int session_expires_in: Optional[int] = None session_token: Optional[str] = None + scope: Optional[str] = None class AnonymousCreateTokenResponse(AnonymousTokenResponse): @@ -940,6 +942,7 @@ class AnonymousTokenSetEntry(BaseModel): expires_at: int audience: Optional[str] = None scope: Optional[str] = None + granted_scope: Optional[str] = None class AnonymousSessionContext(BaseModel): diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index ce158b36..738df27d 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -103,7 +103,7 @@ def _make_jwt(sub: str = "anon@test-uuid") -> str: def _stored_context(store: OneSlotStore, **overrides): - ts_keys = {"access_token", "expires_at", "audience", "scope"} + ts_keys = {"access_token", "expires_at", "audience", "scope", "granted_scope"} ts_defaults = { "access_token": "AT1", "expires_at": int(time.time()) + 3600, @@ -485,6 +485,17 @@ async def test_create_session_tolerates_missing_session_expires_in(self): session = await client.create_session() assert session.session_expires_at is None + @pytest.mark.asyncio + async def test_create_session_surfaces_granted_scope(self): + """Platform-granted scope is returned in AnonymousSession.scope.""" + store = OneSlotStore() + client = _make_client(anonymous_store=store) + response = {**_token_response(), "scope": "read:cart"} + fake_http = _FakeAsyncClient([_fake_response(200, response)]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session(scope="read:cart write:cart") + assert session.scope == "read:cart" + # ── get_token (renewal ladder) ──────────────────────────────────────────────── @@ -610,6 +621,29 @@ async def test_remint_preserves_session_expires_at_when_platform_omits_session_e session = await client.get_token() assert session.session_expires_at == stored_expiry + @pytest.mark.asyncio + async def test_remint_surfaces_granted_scope(self): + """Platform-granted scope from a remint is returned in AnonymousSession.scope.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + response = {**_token_response(access_token="AT2"), "scope": "read:cart"} + fake_http = _FakeAsyncClient([_fake_response(200, response)]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token(scope="read:cart write:cart") + assert session.scope == "read:cart" + + @pytest.mark.asyncio + async def test_cached_token_surfaces_stored_granted_scope(self): + """A cached token hit returns the previously stored granted_scope.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) + 3600, granted_scope="read:cart") + 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.scope == "read:cart" + @pytest.mark.asyncio async def test_remint_preserves_empty_string_fields_instead_of_falling_back_to_stale_context( self, From 36fb8ac452975aca5b466826942b170461a6d9b5 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 11:56:54 +0530 Subject: [PATCH 36/49] fix: fail closed on null stored domain in resolver mode for domain guard --- .../auth_server/anonymous_client.py | 14 +++++--- .../tests/test_anonymous_client.py | 32 ++++++++++++++++++- 2 files changed, 41 insertions(+), 5 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 7a43039e..e8205e67 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -569,9 +569,12 @@ async def exchange_transfer_token_for_injection( except (_AnonymousSessionExpired, _SessionDecryptError): return None # Prevents a tenant-A session token from minting a transfer ticket usable at tenant-B's login. - if context.domain and self._normalize_url(context.domain) != self._normalize_url( + # In resolver mode, an unknown stored domain (legacy session) is treated as a mismatch. + domain_unknown = not context.domain and self._domain_resolver is not None + domain_mismatch = context.domain and self._normalize_url(context.domain) != self._normalize_url( origin_domain - ): + ) + if domain_unknown or domain_mismatch: return None return await self._mint_transfer_token(context.session_token, origin_domain) @@ -707,9 +710,12 @@ async def get_token( ) from e current_domain = await self._resolve_domain(store_options) - if context.domain and self._normalize_url(context.domain) != self._normalize_url( + # In resolver mode, an unknown stored domain (legacy session) is treated as a mismatch. + domain_unknown = not context.domain and self._domain_resolver is not None + domain_mismatch = context.domain and self._normalize_url(context.domain) != self._normalize_url( current_domain - ): + ) + if domain_unknown or domain_mismatch: return await self._create_session_at( current_domain, audience=eff_audience, diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 738df27d..1416d847 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -1006,6 +1006,21 @@ async def test_domain_mismatch_in_resolver_mode_mints_fresh_under_current_tenant _, url, _ = fake_http.calls[0] assert urlsplit(url).hostname == "tenant-b.auth0.local" + @pytest.mark.asyncio + async def test_null_domain_in_resolver_mode_treated_as_mismatch(self): + """A legacy session with no stored domain must not be sent to an unknown resolver tenant.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) + 3600) # domain defaults to None + 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())]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, url, _ = fake_http.calls[0] + assert urlsplit(url).hostname == "tenant-b.auth0.local" + @pytest.mark.asyncio async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(self): store = OneSlotStore() @@ -1122,7 +1137,7 @@ async def test_success_returns_ticket_with_correct_request_body(self): @pytest.mark.asyncio async def test_missing_context_domain_mints_against_origin(self): - """A stored context with no domain is not an MCD mismatch, so mint against origin.""" + """A stored context with no domain is not an MCD mismatch on a static-domain client.""" store = OneSlotStore() _stored_context(store, session_token="REAL_TOKEN") # domain defaults to None client = _make_client(anonymous_store=store) @@ -1133,6 +1148,21 @@ async def test_missing_context_domain_mints_against_origin(self): _, url, _ = fake_http.calls[0] assert urlsplit(url).hostname == "auth0.local" + @pytest.mark.asyncio + async def test_null_domain_in_resolver_mode_returns_none(self): + """A legacy session with no stored domain must not mint a transfer ticket in resolver mode.""" + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN") # domain defaults to None + resolver = AsyncMock(return_value="tenant-b.auth0.local") + client = AnonymousClient( + domain=resolver, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, + secret=SECRET, anonymous_store=store, + ) + with patch("httpx.AsyncClient") as mock_http: + result = await client.exchange_transfer_token_for_injection("tenant-b.auth0.local") + assert result is None + mock_http.assert_not_called() + @pytest.mark.asyncio async def test_returns_none_without_store(self): client = _make_client(anonymous_store=None) From 1f3c9cee5ab959bd3acc2ec38fade9a16811e593 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 15:17:56 +0530 Subject: [PATCH 37/49] fix: wire clear_anonymous_session_on_login to backchannel, passkey, and CTE login paths --- src/auth0_server_python/auth_server/server_client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 231796a5..4ac77aa9 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -1925,6 +1925,7 @@ async def login_backchannel( state_data["domain"] = domain await self._state_store.set(self._state_identifier, state_data, store_options) + await self._clear_anonymous_session_after_login(store_options) result = { "authorization_details": token_endpoint_response.get("authorization_details") @@ -3252,6 +3253,7 @@ async def login_with_custom_token_exchange( # Store session await self._state_store.set(self._state_identifier, state_data, options=store_options) + await self._clear_anonymous_session_after_login(store_options) # Build result result = LoginWithCustomTokenExchangeResult( @@ -3870,6 +3872,7 @@ async def signin_with_passkey( ) await self._state_store.set(self._state_identifier, state_data, options=store_options) + await self._clear_anonymous_session_after_login(store_options) return PasskeyLoginResult(state_data=state_data.model_dump()) From d575aaedb536dfe8a72c43a8cea4b10bfcc034df Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 15:24:04 +0530 Subject: [PATCH 38/49] feat: support private_key_jwt authentication for anonymous session operations --- .../auth_server/anonymous_client.py | 42 +++++-- .../auth_server/server_client.py | 2 + .../tests/test_anonymous_client.py | 104 ++++++++++++++++++ 3 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index e8205e67..ba21942d 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -11,6 +11,11 @@ import httpx from pydantic import ValidationError +from auth0_server_python.auth_schemes.client_assertion import ( + CLIENT_ASSERTION_TYPE, + build_client_assertion, + validate_client_assertion_key, +) from auth0_server_python.auth_types import ( AnonymousCreateTokenResponse, AnonymousSession, @@ -53,12 +58,14 @@ def __init__( self, domain: Union[str, Callable, None], client_id: str, - client_secret: str, + client_secret: Optional[str], secret: str, anonymous_store=None, default_audience: Optional[str] = None, default_scope: Optional[str] = None, headers: Optional[dict[str, str]] = None, + client_assertion_signing_key: Optional[str] = None, + client_assertion_signing_alg: Optional[str] = None, ): if callable(domain): self._domain = None @@ -68,6 +75,12 @@ def __init__( self._domain_resolver = None self._client_id = client_id self._client_secret = client_secret + self._client_assertion_signing_key = client_assertion_signing_key + self._client_assertion_signing_alg = client_assertion_signing_alg or "RS256" + if client_assertion_signing_key: + validate_client_assertion_key( + client_assertion_signing_key, self._client_assertion_signing_alg + ) self._secret = secret self._anonymous_store = anonymous_store self._default_audience = default_audience @@ -99,6 +112,24 @@ def _require_store(self) -> None: "store instance can silently overwrite the authenticated session." ) + def _apply_client_auth(self, body: dict[str, Any], domain: str) -> None: + """Inject client credentials into a JSON request body. + + Args: + body: The outgoing request body dict, mutated in place. + domain: The target tenant domain, used as the assertion audience. + """ + if self._client_assertion_signing_key: + body["client_assertion"] = build_client_assertion( + self._client_assertion_signing_key, + self._client_id, + f"https://{domain}/", + self._client_assertion_signing_alg, + ) + body["client_assertion_type"] = CLIENT_ASSERTION_TYPE + elif self._client_secret: + body["client_secret"] = self._client_secret + async def _resolve_domain(self, store_options: Optional[dict[str, Any]] = None) -> str: """Resolve the tenant domain from the configured resolver or static value. @@ -353,8 +384,7 @@ async def _create_session_at( """ base_url = f"https://{domain}" payload: dict[str, Any] = {"client_id": self._client_id} - if self._client_secret: - payload["client_secret"] = self._client_secret + self._apply_client_auth(payload, domain) if audience: payload["audience"] = audience if scope: @@ -456,8 +486,7 @@ async def _remint( "client_id": self._client_id, "session_token": context.session_token, } - if self._client_secret: - body["client_secret"] = self._client_secret + self._apply_client_auth(body, domain) if audience: body["audience"] = audience if scope: @@ -596,8 +625,7 @@ async def _mint_transfer_token( "session_token": session_token, "audience": TRANSFER_AUDIENCE, } - if self._client_secret: - body["client_secret"] = self._client_secret + self._apply_client_auth(body, origin_domain) try: async with self._get_http_client() as client: diff --git a/src/auth0_server_python/auth_server/server_client.py b/src/auth0_server_python/auth_server/server_client.py index 4ac77aa9..65f61e11 100644 --- a/src/auth0_server_python/auth_server/server_client.py +++ b/src/auth0_server_python/auth_server/server_client.py @@ -346,6 +346,8 @@ def __init__( if isinstance(self._default_authorization_params.get("scope"), str) else None, headers=self._telemetry_headers, + client_assertion_signing_key=self._client_assertion_signing_key, + client_assertion_signing_alg=self._client_assertion_signing_alg, ) self._passwordless_client = PasswordlessClient(self) diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 1416d847..a08244de 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -10,6 +10,8 @@ import httpx import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import rsa from auth0_server_python.auth_server.anonymous_client import ( ANON_IDENTIFIER, @@ -48,6 +50,15 @@ def _make_client(anonymous_store=None, **kwargs) -> AnonymousClient: ) +def _generate_rsa_private_key_pem() -> str: + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + return key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode("ascii") + + def _fake_response(status_code=200, body=None): response = MagicMock() response.status_code = status_code @@ -148,6 +159,30 @@ def test_no_dpop_key_parameter_exists(self): sig = inspect.signature(method) assert "dpop_key" not in sig.parameters, f"{name} must never accept dpop_key" + def test_constructor_accepts_private_key_jwt_params(self): + signing_key = _generate_rsa_private_key_pem() + client = AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + client_assertion_signing_key=signing_key, + client_assertion_signing_alg="RS256", + ) + assert client._client_assertion_signing_key == signing_key + assert client._client_assertion_signing_alg == "RS256" + assert client._client_secret is None + + def test_constructor_rejects_invalid_signing_key(self): + with pytest.raises(ConfigurationError): + AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + client_assertion_signing_key="not-a-valid-pem-key", + ) + # ── Store isolation ──────────────────────────────────────────────────────────── @@ -259,6 +294,28 @@ async def test_create_session_sends_client_secret_in_json_body_not_auth_tuple(se assert kwargs["json"]["client_secret"] == CLIENT_SECRET assert "auth" not in kwargs + @pytest.mark.asyncio + @pytest.mark.asyncio + async def test_create_session_uses_client_assertion_when_signing_key_set(self): + store = OneSlotStore() + signing_key = _generate_rsa_private_key_pem() + client = AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + anonymous_store=store, + client_assertion_signing_key=signing_key, + ) + 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] + body = kwargs["json"] + assert "client_assertion" in body + assert body.get("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert "client_secret" not in body + @pytest.mark.asyncio async def test_create_session_never_attaches_dpop_header(self): store = OneSlotStore() @@ -586,6 +643,29 @@ async def test_remint_omits_audience_and_scope_when_the_session_had_none(self): assert "audience" not in kwargs["json"] assert "scope" not in kwargs["json"] + @pytest.mark.asyncio + async def test_remint_uses_client_assertion_when_signing_key_set(self): + """Renewal request must carry client_assertion, not client_secret, for private_key_jwt clients.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + signing_key = _generate_rsa_private_key_pem() + client = AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + anonymous_store=store, + client_assertion_signing_key=signing_key, + ) + fake_http = _FakeAsyncClient([_fake_response(200, _token_response(access_token="AT2"))]) + with patch("httpx.AsyncClient", fake_http): + await client.get_token() + _, _, kwargs = fake_http.calls[0] + body = kwargs["json"] + assert "client_assertion" in body + assert body.get("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert "client_secret" not in body + @pytest.mark.asyncio async def test_remint_never_sends_metadata_in_body(self): """Platform 400s if metadata is included in a renewal request.""" @@ -1135,6 +1215,30 @@ async def test_success_returns_ticket_with_correct_request_body(self): assert body["client_id"] == CLIENT_ID assert body["client_secret"] == CLIENT_SECRET + @pytest.mark.asyncio + async def test_transfer_token_uses_client_assertion_when_signing_key_set(self): + """Transfer-ticket request must carry client_assertion, not client_secret, for private_key_jwt clients.""" + store = OneSlotStore() + _stored_context(store, session_token="REAL_TOKEN", domain="auth0.local") + signing_key = _generate_rsa_private_key_pem() + client = AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + anonymous_store=store, + client_assertion_signing_key=signing_key, + ) + fake_http = _FakeAsyncClient([_fake_response(200, _TRANSFER_OK)]) + with patch("httpx.AsyncClient", fake_http): + ticket = await client.exchange_transfer_token_for_injection("auth0.local") + assert ticket == "TICKET" + _, _, kwargs = fake_http.calls[0] + body = kwargs["json"] + assert "client_assertion" in body + assert body.get("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" + assert "client_secret" not in body + @pytest.mark.asyncio async def test_missing_context_domain_mints_against_origin(self): """A stored context with no domain is not an MCD mismatch on a static-domain client.""" From 0cd4f2b7b3dd2f578ab35de56bd84b9bff89b174 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 15:38:29 +0530 Subject: [PATCH 39/49] fix: do not forward metadata on MCD domain mismatch re-mint --- src/auth0_server_python/auth_server/anonymous_client.py | 2 +- src/auth0_server_python/tests/test_anonymous_client.py | 6 +++--- 2 files changed, 4 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 ba21942d..3399f02d 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -748,7 +748,7 @@ async def get_token( current_domain, audience=eff_audience, scope=eff_scope, - metadata=context.metadata, + metadata=None, store_options=store_options, ) diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index a08244de..2ecc529f 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -1115,8 +1115,8 @@ async def test_domain_mismatch_in_static_mode_mints_fresh_under_current_tenant(s assert urlsplit(url).hostname != "tenant-a.auth0.local" @pytest.mark.asyncio - async def test_domain_mismatch_remint_preserves_metadata(self): - """A domain-mismatch re-mint must carry the stored metadata, not drop the cart.""" + async def test_domain_mismatch_remint_does_not_forward_metadata(self): + """A domain-mismatch re-mint must not carry metadata to the new domain.""" store = OneSlotStore() _stored_context( store, @@ -1133,7 +1133,7 @@ async def test_domain_mismatch_remint_preserves_metadata(self): await client.get_token() _, url, kwargs = fake_http.calls[0] assert urlsplit(url).hostname == "tenant-b.auth0.local" - assert kwargs["json"]["metadata"] == {"cart": ["sku-1"]} + assert "metadata" not in kwargs["json"] @pytest.mark.asyncio async def test_domain_resolver_failure_propagates(self): From c95980a04161a5d22665785afde802fcd153b6ae Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 15:41:54 +0530 Subject: [PATCH 40/49] fix: remove session_token from AnonymousSession public model --- .../auth_server/anonymous_client.py | 3 -- .../auth_types/__init__.py | 1 - .../tests/test_anonymous_client.py | 54 +++---------------- 3 files changed, 7 insertions(+), 51 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 3399f02d..9c7cbfd6 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -447,7 +447,6 @@ async def _create_session_at( ) return AnonymousSession( access_token=token_set.access_token, - session_token=context.session_token, expires_at=token_set.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, @@ -533,7 +532,6 @@ async def _remint( ) result = AnonymousSession( access_token=token_set.access_token, - session_token=new_session_token, expires_at=token_set.expires_at, session_expires_at=now + token_response.session_expires_in if token_response.session_expires_in is not None else context.session_expires_at, metadata=context.metadata, @@ -757,7 +755,6 @@ async def get_token( if token_set and token_set.expires_at > now: return AnonymousSession( access_token=token_set.access_token, - session_token=context.session_token, expires_at=token_set.expires_at, session_expires_at=context.session_expires_at, metadata=context.metadata, diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 88f22d26..a5916ec8 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -889,7 +889,6 @@ class AnonymousSession(BaseModel): """Public result of create_session() and the renewal ladder.""" access_token: str - session_token: str expires_at: int session_expires_at: Optional[int] = None metadata: Optional[dict[str, Any]] = None diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 2ecc529f..7d925202 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -18,6 +18,7 @@ AnonymousClient, ) from auth0_server_python.auth_types import ( + AnonymousSession, AnonymousSessionContext, AnonymousSessionData, AnonymousTokenSetEntry, @@ -229,46 +230,9 @@ async def test_create_session_success(self): ) assert session.metadata == {"cart_id": "c1"} - @pytest.mark.asyncio - async def test_create_session_exposes_session_token_to_caller(self): - """create_session() must expose session_token to the caller.""" - 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="aud", scope="s") - assert session.session_token == "ST1" - - @pytest.mark.asyncio - async def test_get_token_exposes_session_token_on_cached_and_reminted_paths(self): - """get_token() must carry session_token on both cached and re-minted paths.""" - store = OneSlotStore() - client = _make_client(anonymous_store=store) - - _stored_context(store) - cached = await client.get_token() - assert cached.session_token == "ST1" - - _stored_context(store, expires_at=int(time.time()) - 10) - fake_http = _FakeAsyncClient( - [_fake_response(200, _token_response(access_token="AT2", session_token="ST2"))] - ) - with patch("httpx.AsyncClient", fake_http): - reminted = await client.get_token() - assert reminted.session_token == "ST2" - - @pytest.mark.asyncio - async def test_remint_without_session_token_keeps_exposing_prior_token(self): - """Re-mint response omitting session_token must keep exposing the prior one.""" - store = OneSlotStore() - client = _make_client(anonymous_store=store) - _stored_context(store, expires_at=int(time.time()) - 10) - response = _token_response(access_token="AT2") - del response["session_token"] - fake_http = _FakeAsyncClient([_fake_response(200, response)]) - with patch("httpx.AsyncClient", fake_http): - session = await client.get_token() - assert session.session_token == "ST1" + def test_anonymous_session_does_not_expose_session_token(self): + """session_token is a server credential and must not appear on the public return type.""" + assert "session_token" not in AnonymousSession.model_fields @pytest.mark.asyncio async def test_create_session_response_missing_session_token_raises(self): @@ -1445,13 +1409,9 @@ async def test_returns_session_data_with_identity_fields(self): assert result.sub == "anon@test-uuid" assert result.domain == "auth0.local" - @pytest.mark.asyncio - async def test_does_not_include_session_token(self): - store = OneSlotStore() - _stored_context(store) - client = _make_client(anonymous_store=store) - result = await client.get_session() - assert not hasattr(result, "session_token") or not isinstance(getattr(result, "session_token", None), str) + def test_does_not_include_session_token(self): + """session_token must not be declared on AnonymousSessionData's schema.""" + assert "session_token" not in AnonymousSessionData.model_fields @pytest.mark.asyncio async def test_makes_no_http_call(self): From b7377117047f134b228881ae9e5a19cb6ae46aff Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 16:30:11 +0530 Subject: [PATCH 41/49] fix: correct base64 padding formula in _decode_sub --- src/auth0_server_python/auth_server/anonymous_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index 9c7cbfd6..c6bce257 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -198,7 +198,7 @@ def _decode_sub(access_token: str) -> Optional[str]: parts = access_token.split(".") if len(parts) != 3: return None - padded = parts[1] + "=" * (4 - len(parts[1]) % 4) + padded = parts[1] + "=" * (-len(parts[1]) % 4) payload = json.loads(base64.urlsafe_b64decode(padded)) sub = payload.get("sub") return str(sub) if sub else None From 92f9827583090e995be569976b7a93ee83c2419e Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 16:32:10 +0530 Subject: [PATCH 42/49] test: strengthen silent-create assertion on session_expired path --- src/auth0_server_python/tests/test_anonymous_client.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 7d925202..83a34a72 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -723,8 +723,11 @@ async def test_expired_session_token_triggers_silent_new_session(self): _fake_response(200, _token_response()), ]) with patch("httpx.AsyncClient", fake_http): - await client.get_token() + session = await client.get_token() assert len(fake_http.calls) == 2 + _, _, second_call = fake_http.calls[1] + assert "session_token" not in second_call["json"] + assert session.access_token == "AT1" @pytest.mark.asyncio async def test_silent_remint_drops_metadata(self): From 76509e7536c078a43e84821dff5b463fbab15368 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 22:31:18 +0530 Subject: [PATCH 43/49] fix: reparent _AnonymousSessionExpired under AnonymousSessionError --- src/auth0_server_python/error/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/auth0_server_python/error/__init__.py b/src/auth0_server_python/error/__init__.py index b905ab39..84c3870f 100644 --- a/src/auth0_server_python/error/__init__.py +++ b/src/auth0_server_python/error/__init__.py @@ -458,11 +458,11 @@ def __init__(self, message: str, code: str = "anonymous_token_error", cause: Opt super().__init__(code, message, cause) -class _AnonymousSessionExpired(Auth0Error): +class _AnonymousSessionExpired(AnonymousSessionError): """Internal-only signal that the stored session token is expired or invalid.""" def __init__(self, message: str = "The anonymous session token is expired or invalid."): - super().__init__(message) + super().__init__("session_expired", message) self.name = "_AnonymousSessionExpired" From 38fd3fc12dc2f3b754ef4c36543e468424fe3258 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 22:39:06 +0530 Subject: [PATCH 44/49] fix: validate anon@ sub shape in _decode_sub, remove JS-only prototype key guard --- .../auth_server/anonymous_client.py | 10 ++--- .../tests/test_anonymous_client.py | 43 ++++++++++++++++--- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/auth0_server_python/auth_server/anonymous_client.py b/src/auth0_server_python/auth_server/anonymous_client.py index c6bce257..a084ad5a 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -48,7 +48,6 @@ TRANSFER_AUDIENCE = "urn:auth0:anon_transfer" _METADATA_MAX_BYTES = 1024 -_DANGEROUS_METADATA_KEYS = frozenset({"__proto__", "constructor", "prototype"}) class AnonymousClient: @@ -201,7 +200,9 @@ def _decode_sub(access_token: str) -> Optional[str]: padded = parts[1] + "=" * (-len(parts[1]) % 4) payload = json.loads(base64.urlsafe_b64decode(padded)) sub = payload.get("sub") - return str(sub) if sub else None + if not isinstance(sub, str) or not sub.startswith("anon@"): + return None + return sub except Exception: return None @@ -298,11 +299,6 @@ def _validate_metadata(metadata: Optional[dict[str, Any]]) -> None: return if not isinstance(metadata, dict): raise AnonymousSessionCreateError("metadata must be a JSON object", code="invalid_metadata") - for key in metadata: - if key in _DANGEROUS_METADATA_KEYS: - raise AnonymousSessionCreateError( - f"metadata key '{key}' is not allowed", code="invalid_metadata" - ) try: size = len(json.dumps(metadata).encode("utf-8")) except TypeError as e: diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 83a34a72..c2179149 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -313,13 +313,6 @@ async def test_metadata_over_1kb_rejected_client_side_no_network_call(self): 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_accepted(self): store = OneSlotStore() @@ -517,6 +510,42 @@ async def test_create_session_surfaces_granted_scope(self): session = await client.create_session(scope="read:cart write:cart") assert session.scope == "read:cart" + @pytest.mark.asyncio + async def test_non_string_sub_in_jwt_stored_as_none(self): + """A JWT with a non-string sub must not be coerced to a string.""" + store = OneSlotStore() + client = _make_client(anonymous_store=store) + header = _b64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = _b64.urlsafe_b64encode(b'{"sub":12345}').rstrip(b"=").decode() + bad_jwt = f"{header}.{payload}." + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": bad_jwt, + "token_type": "Bearer", + "expires_in": 3600, + "session_token": "ST1", + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session() + assert session.sub is None + + @pytest.mark.asyncio + async def test_sub_without_anon_prefix_stored_as_none(self): + """A JWT sub that does not start with anon@ must be rejected.""" + store = OneSlotStore() + client = _make_client(anonymous_store=store) + jwt_token = _make_jwt(sub="user@unexpected") + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "session_token": "ST1", + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + session = await client.create_session() + assert session.sub is None + # ── get_token (renewal ladder) ──────────────────────────────────────────────── From c09c7e9cc1c3926e0868a5dfe7a63334439dfcea Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 23:17:02 +0530 Subject: [PATCH 45/49] fix: HTTP timeout, token freshness leeway, concurrency write guard, get_session domain scope --- .../auth_server/anonymous_client.py | 8 +++++--- .../tests/test_anonymous_client.py | 20 +++++++++++++++++++ 2 files changed, 25 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 a084ad5a..33bd16cf 100644 --- a/src/auth0_server_python/auth_server/anonymous_client.py +++ b/src/auth0_server_python/auth_server/anonymous_client.py @@ -37,6 +37,7 @@ _SessionDecryptError, ) from auth0_server_python.utils.helpers import ( + State, build_domain_resolver_context, validate_resolved_domain_value, ) @@ -96,6 +97,7 @@ def _get_http_client(self, **kwargs) -> httpx.AsyncClient: A configured httpx.AsyncClient. """ headers = {**kwargs.pop("headers", {}), **self._headers} + kwargs.setdefault("timeout", 5.0) return httpx.AsyncClient(headers=headers, **kwargs) def _require_store(self) -> None: @@ -542,7 +544,7 @@ async def _remint( try: current_context = self._decrypt_context(current_stored) except (_AnonymousSessionExpired, _SessionDecryptError): - current_context = context + return result if current_context.session_token != context.session_token: return result @@ -748,7 +750,7 @@ async def get_token( now = int(time.time()) token_set = self._find_token_set(context.token_sets, eff_audience, eff_scope) - if token_set and token_set.expires_at > now: + if token_set and token_set.expires_at - State.SESSION_EXPIRY_LEEWAY_SECONDS > now: return AnonymousSession( access_token=token_set.access_token, expires_at=token_set.expires_at, @@ -800,7 +802,7 @@ async def get_session( context = self._decrypt_context(stored) except (_AnonymousSessionExpired, _SessionDecryptError): return None - if context.domain: + if context.domain and self._domain_resolver is not None: try: current_domain = await self._resolve_domain(store_options) except Exception: diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index c2179149..c63d7c79 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -184,6 +184,26 @@ def test_constructor_rejects_invalid_signing_key(self): client_assertion_signing_key="not-a-valid-pem-key", ) + @pytest.mark.asyncio + async def test_public_client_sends_no_credentials(self): + """A client with no secret and no signing key sends requests without any auth credential.""" + store = OneSlotStore() + client = AnonymousClient( + domain=DOMAIN, + client_id=CLIENT_ID, + client_secret=None, + secret=SECRET, + 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] + body = kwargs["json"] + assert "client_secret" not in body + assert "client_assertion" not in body + assert "client_assertion_type" not in body + # ── Store isolation ──────────────────────────────────────────────────────────── From f3a7bdfe5803959d98ddd889d878e993005c5a40 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 23:20:18 +0530 Subject: [PATCH 46/49] test: add sub backfill test; fix stale session_token and dangerous-key refs in examples --- examples/AnonymousSessions.md | 6 +++--- .../tests/test_anonymous_client.py | 20 +++++++++++++++++++ 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index bcfd98cf..cb279887 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -46,9 +46,9 @@ session = await server_client.anonymous.create_session( ) ``` -`metadata` is **set once, at creation, and never updated**. 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. +`metadata` is **set once, at creation, and never updated**. Any JSON-serializable value is accepted, ≤1 KB total (UTF-8 JSON byte length). Oversized or non-JSON-serializable metadata is rejected client-side before any network call. -`AnonymousSession` returns `session_token`, `access_token`, `expires_at`, `session_expires_at`, and `metadata`. +`AnonymousSession` returns `access_token`, `expires_at`, `session_expires_at`, `metadata`, `sub`, and `scope`. ## Getting a Token @@ -60,7 +60,7 @@ 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, since 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 `session_token` changes. This never raises. An anonymous pre-login session carries no authorization, so re-minting crosses no trust boundary. +3. Session token also expired or invalid, a **brand-new session is silently created, once**. Both `sub` and `metadata` reset: the visitor gets a new `anon@` identity and any previously attached metadata is gone. 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. ## Logging Out diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index c63d7c79..397c7ed2 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -1082,6 +1082,26 @@ async def test_remint_does_not_overwrite_existing_sub(self): ctx = client._decrypt_context(stored) assert ctx.sub == "anon@original-uuid" + @pytest.mark.asyncio + async def test_remint_backfills_sub_when_initial_token_was_jwe(self): + """When sub was None (initial JWE token), a remint returning a JWT must backfill sub.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10, sub=None) + client = _make_client(anonymous_store=store) + jwt_token = _make_jwt("anon@backfilled-uuid") + fake_http = _FakeAsyncClient([_fake_response(200, { + "access_token": jwt_token, + "token_type": "Bearer", + "expires_in": 3600, + "session_expires_in": 2592000, + })]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + assert session.sub == "anon@backfilled-uuid" + stored = await store.get(ANON_IDENTIFIER) + ctx = client._decrypt_context(stored) + assert ctx.sub == "anon@backfilled-uuid" + # ── MCD / cross-tenant isolation ─────────────────────────────────────────────── From d404a38c909aa9d7a7e423d15e3e597f795fba4c Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 23:26:47 +0530 Subject: [PATCH 47/49] test: cover concurrent re-read decrypt failure and get_session resolver error paths --- .../tests/test_anonymous_client.py | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index 397c7ed2..dad94973 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -1034,6 +1034,33 @@ async def get_and_replace(identifier, *, options=None): final_ctx = client._decrypt_context(stored) assert final_ctx.session_token == "NEW_SESSION_TOKEN" + @pytest.mark.asyncio + async def test_remint_skips_write_when_reread_is_corrupt(self): + """If the re-read context is corrupt, the result is returned without writing.""" + store = OneSlotStore() + _stored_context(store, expires_at=int(time.time()) - 10) + client = _make_client(anonymous_store=store) + + original_get = store.get + call_count = 0 + + async def get_then_corrupt(identifier, *, options=None): + nonlocal call_count + call_count += 1 + if call_count == 2: + store.slot = (ANON_IDENTIFIER, {"context": "not-valid-jwe"}) + return await original_get(identifier) + + store.get = get_then_corrupt + fake_http = _FakeAsyncClient([_fake_response(200, _token_response(access_token="AT2"))]) + with patch("httpx.AsyncClient", fake_http): + session = await client.get_token() + + assert session.access_token == "AT2" + stored_raw = store.slot + assert stored_raw is not None + assert stored_raw[1]["context"] == "not-valid-jwe" + @pytest.mark.asyncio async def test_get_token_returns_sub_from_cache(self): store = OneSlotStore() @@ -1533,3 +1560,27 @@ async def test_domain_match_in_resolver_mode_returns_session(self): ) result = await client.get_session() assert result is not None + + @pytest.mark.asyncio + async def test_resolver_exception_in_get_session_returns_none(self): + """A resolver error during get_session must fail closed.""" + store = OneSlotStore() + _stored_context(store, domain="tenant-a.auth0.local") + resolver = AsyncMock(side_effect=RuntimeError("resolver down")) + client = AnonymousClient( + domain=resolver, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, + secret=SECRET, anonymous_store=store, + ) + result = await client.get_session() + assert result is None + + @pytest.mark.asyncio + async def test_static_domain_client_returns_session_without_domain_check(self): + """A static-domain client must not invoke the resolver and must return the session.""" + store = OneSlotStore() + _stored_context(store, domain="some-old-domain.auth0.local") + client = _make_client(anonymous_store=store) + with patch.object(client, "_resolve_domain") as mock_resolver: + result = await client.get_session() + mock_resolver.assert_not_called() + assert result is not None From 0c5e37c7f361c213ef8f469748bfa5023d7354c1 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 23:29:17 +0530 Subject: [PATCH 48/49] docs: document anon error hierarchy, codes, and AnonymousSessionTokenError naming --- examples/AnonymousSessions.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index cb279887..de334614 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -102,3 +102,33 @@ except AnonymousSessionCreateError as e: if e.code == "feature_not_enabled": ... ``` + +### Error Hierarchy + +``` +AnonymousSessionError base class, never raised directly + AnonymousSessionCreateError raised by create_session() + AnonymousSessionTokenError raised by get_token() +``` + +### `AnonymousSessionCreateError` codes + +| `.code` | When | +|---------|------| +| `"feature_not_enabled"` | anonymous sessions not enabled on this tenant/client | +| `"anonymous_create_error"` | generic platform error on the create path | +| `"missing_session_token"` | platform response omitted the session token (misconfigured tenant) | +| `"invalid_metadata"` | metadata is not a dict or contains non-JSON-serializable values | +| `"metadata_too_large"` | metadata exceeds the 1 KB limit | +| `"invalid_options"` | unrecognised key in `create_session()` options | + +The platform may return other codes (e.g. `"insufficient_scope"`); these are passed through on `.code` unchanged. + +### `AnonymousSessionTokenError` codes + +| `.code` | When | +|---------|------| +| `"invalid_session_state"` | stored session could not be decrypted; call `create_session()` to recover | +| `"anonymous_token_error"` | no active session, network error, parse error, or generic platform error on the renewal path | + +> **Note on naming.** The SDK spec names this class `AnonymousSessionTokenExpiredError`. This SDK uses `AnonymousSessionTokenError` - a deliberate broadening, since the class covers all `get_token()` failures, not just expiry. The `.code` values are stable and safe to branch on. From 3da7b586bcbfb747d8dadbb251302debfc6a1abd Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 29 Sep 2026 23:32:20 +0530 Subject: [PATCH 49/49] style: replace semicolons with plain connectors in test docstrings and examples --- examples/AnonymousSessions.md | 4 ++-- src/auth0_server_python/tests/test_anonymous_client.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/AnonymousSessions.md b/examples/AnonymousSessions.md index de334614..d538540a 100644 --- a/examples/AnonymousSessions.md +++ b/examples/AnonymousSessions.md @@ -122,13 +122,13 @@ AnonymousSessionError base class, never raised directly | `"metadata_too_large"` | metadata exceeds the 1 KB limit | | `"invalid_options"` | unrecognised key in `create_session()` options | -The platform may return other codes (e.g. `"insufficient_scope"`); these are passed through on `.code` unchanged. +The platform may return other codes (e.g. `"insufficient_scope"`) and these are passed through on `.code` unchanged. ### `AnonymousSessionTokenError` codes | `.code` | When | |---------|------| -| `"invalid_session_state"` | stored session could not be decrypted; call `create_session()` to recover | +| `"invalid_session_state"` | stored session could not be decrypted - call `create_session()` to recover | | `"anonymous_token_error"` | no active session, network error, parse error, or generic platform error on the renewal path | > **Note on naming.** The SDK spec names this class `AnonymousSessionTokenExpiredError`. This SDK uses `AnonymousSessionTokenError` - a deliberate broadening, since the class covers all `get_token()` failures, not just expiry. The `.code` values are stable and safe to branch on. diff --git a/src/auth0_server_python/tests/test_anonymous_client.py b/src/auth0_server_python/tests/test_anonymous_client.py index dad94973..b788bcdb 100644 --- a/src/auth0_server_python/tests/test_anonymous_client.py +++ b/src/auth0_server_python/tests/test_anonymous_client.py @@ -510,7 +510,7 @@ async def test_create_session_stores_none_sub_for_opaque_token(self): @pytest.mark.asyncio async def test_create_session_tolerates_missing_session_expires_in(self): - """Legacy platform tokens omit session_expires_in; session_expires_at must be None.""" + """Legacy platform tokens omit session_expires_in so session_expires_at must be None.""" store = OneSlotStore() client = _make_client(anonymous_store=store) response = {k: v for k, v in _token_response().items() if k != "session_expires_in"} @@ -703,7 +703,7 @@ async def test_remint_never_sends_metadata_in_body(self): @pytest.mark.asyncio async def test_remint_preserves_session_expires_at_when_platform_omits_session_expires_in(self): - """Legacy renewal responses omit session_expires_in; stored expiry must be kept.""" + """Legacy renewal responses omit session_expires_in so stored expiry must be kept.""" stored_expiry = int(time.time()) + 86400 store = OneSlotStore() _stored_context(store, expires_at=int(time.time()) - 10, session_expires_at=stored_expiry)