Skip to content

Commit a2a0afe

Browse files
committed
docs: clean up, formatting improvement and docs content update
1 parent 796c9ed commit a2a0afe

8 files changed

Lines changed: 330 additions & 272 deletions

File tree

examples/AnonymousSessions.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,6 @@ except AnonymousFeatureNotEnabledError:
140140
...
141141
```
142142

143-
`.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.
144-
145143
## Known Limitations
146144

147145
- **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.

src/auth0_server_python/auth_server/anonymous_client.py

Lines changed: 278 additions & 157 deletions
Large diffs are not rendered by default.

src/auth0_server_python/auth_server/server_client.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -138,13 +138,12 @@ def __init__(
138138
transaction_store: Custom transaction store (defaults to MemoryTransactionStore)
139139
state_store: Custom state store (defaults to MemoryStateStore)
140140
anonymous_store: Store for anonymous session state (server_client.anonymous.*).
141-
Must be a distinct store *instance* from state_store — not merely a
142-
different identifier. On the default auth0-fastapi cookie stores, a
143-
store identifier is used only as an encryption salt, not a location
144-
key, so writing anonymous state through state_store would silently
145-
overwrite the authenticated session cookie. When omitted, the
146-
`.anonymous` sub-client fails closed on first use rather than
147-
sharing state_store implicitly.
141+
Must be a distinct store *instance* from state_store, not merely a
142+
different identifier. On a store where the identifier is used only
143+
as an encryption salt rather than a location key, writing anonymous
144+
state through state_store would silently overwrite the authenticated
145+
session cookie. When omitted, the `.anonymous` sub-client fails
146+
closed on first use rather than sharing state_store implicitly.
148147
transaction_identifier: Identifier for transaction data
149148
state_identifier: Identifier for state data
150149
authorization_params: Default parameters for authorization requests
@@ -571,7 +570,7 @@ async def start_interactive_login(
571570

572571
# session_token is sourced only from the SDK's own encrypted anonymous
573572
# store, never from a caller. INTERNAL_AUTHORIZE_PARAMS alone isn't
574-
# enough auth_params is seeded unfiltered from the constructor
573+
# enough, since auth_params is seeded unfiltered from the constructor
575574
# defaults above, so a caller-supplied value would survive that filter.
576575
# Suppressed entirely on the PAR branch below (unsupported there).
577576
auth_params.pop("session_token", None)

src/auth0_server_python/auth_types/__init__.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -864,12 +864,13 @@ class AnonymousSession(BaseModel):
864864
"""
865865
Public result of create_session() / the renewal ladder.
866866
867-
Never exposes the raw session token — that stays inside the encrypted
867+
Never exposes the raw session token, which stays inside the encrypted
868868
AnonymousSessionContext, server-side only.
869869
"""
870870

871-
sub: str
872-
session_id: str
871+
# Optional: the platform's /anonymous/token response doesn't always include these.
872+
sub: Optional[str] = None
873+
session_id: Optional[str] = None
873874
access_token: str
874875
expires_at: int
875876
session_expires_at: Optional[int] = None
@@ -879,9 +880,9 @@ class AnonymousSession(BaseModel):
879880

880881
class AnonymousSessionIntrospection(BaseModel):
881882
"""
882-
Result of introspect(). Deliberately minimal and lenient the platform's
883-
/anonymous/userinfo response shape is unconfirmed; unrecognized fields
884-
are ignored rather than rejected.
883+
Result of introspect(). Deliberately minimal and lenient, since the
884+
platform's /anonymous/userinfo response shape is unconfirmed.
885+
Unrecognized fields are ignored rather than rejected.
885886
"""
886887

887888
model_config = ConfigDict(extra="ignore")
@@ -907,13 +908,14 @@ class AnonymousSessionContext(BaseModel):
907908
"""
908909
Internal context stored inside the encrypted anonymous session record.
909910
910-
No `extra` config decrypt fails closed on a tampered or malformed
911+
No `extra` config, so decrypt fails closed on a tampered or malformed
911912
payload rather than silently yielding a partial object.
912913
"""
913914

914915
session_token: str
915-
sub: str
916-
session_id: str
916+
# sub/session_id: optional for the same reason as AnonymousSession above.
917+
sub: Optional[str] = None
918+
session_id: Optional[str] = None
917919
access_token: str
918920
expires_at: int
919921
session_expires_at: Optional[int] = None

src/auth0_server_python/error/__init__.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -369,13 +369,7 @@ class PasskeyErrorCode:
369369
# =============================================================================
370370

371371
class AnonymousApiError(Auth0Error):
372-
"""
373-
Base class for anonymous session API errors.
374-
375-
Scrubs Tier 0/1 secret fields (client_secret, session_token, access_token,
376-
assertion, client_assertion) out of `cause` recursively before storing it,
377-
so `.cause` is always safe to log or surface.
378-
"""
372+
"""Base class for anonymous session API errors."""
379373

380374
def __init__(
381375
self,
@@ -385,11 +379,6 @@ def __init__(
385379
):
386380
super().__init__(message)
387381
self.code = code
388-
if cause is not None:
389-
# Deferred import: utils.helpers imports from this module at load
390-
# time, so a module-level import here would cycle.
391-
from auth0_server_python.utils.helpers import scrub_secrets # noqa: PLC0415
392-
cause = scrub_secrets(cause)
393382
self.cause = cause
394383

395384

@@ -418,8 +407,8 @@ class AnonymousSessionIntrospectError(AnonymousApiError):
418407
"""
419408
Error thrown when introspect() fails.
420409
421-
Only raised on a genuine HTTP/auth failure never on an unknown or
422-
missing response field, since the response shape is unconfirmed.
410+
Only raised on a genuine HTTP/auth failure, never on an unknown or
411+
missing response field.
423412
"""
424413

425414
def __init__(self, message: str, cause: Optional[dict] = None):

src/auth0_server_python/tests/test_anonymous_client.py

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
"""
2-
Tests for AnonymousClient anonymous session API operations.
2+
Tests for AnonymousClient, covering anonymous session API operations.
33
"""
44

55
import inspect
@@ -38,9 +38,10 @@
3838
class OneSlotStore:
3939
"""
4040
Models StatelessStateStore: a store identifier is a salt, not a location.
41-
One physical slot per instance — a mismatched identifier reads as absent,
42-
not as a different record. AsyncMock cannot catch a collision because it
43-
treats every identifier as a distinct key; this fake is required instead.
41+
One physical slot per instance, so a mismatched identifier reads as
42+
absent, not as a different record. AsyncMock cannot catch a collision
43+
because it treats every identifier as a distinct key, so this fake is
44+
required instead.
4445
"""
4546

4647
def __init__(self):
@@ -77,7 +78,7 @@ def _fake_response(status_code=200, body=None):
7778

7879

7980
class _FakeAsyncClient:
80-
"""Patches httpx.AsyncClient; call sequence maps 1:1 to responses."""
81+
"""Patches httpx.AsyncClient. Call sequence maps 1:1 to responses."""
8182

8283
def __init__(self, responses):
8384
self._responses = list(responses)
@@ -155,13 +156,13 @@ def test_constructor_accepts_callable_domain(self):
155156
assert client._domain_resolver is resolver
156157

157158
def test_no_dpop_key_parameter_exists(self):
158-
"""Structural guard (D1/§6): AnonymousClient has no dpop_key parameter anywhere."""
159+
"""Structural guard: AnonymousClient has no dpop_key parameter anywhere."""
159160
for name, method in inspect.getmembers(AnonymousClient, predicate=inspect.isfunction):
160161
sig = inspect.signature(method)
161162
assert "dpop_key" not in sig.parameters, f"{name} must never accept dpop_key"
162163

163164

164-
# ── Fail-closed store isolation (D3a / B7) ───────────────────────────────────
165+
# ── Fail-closed store isolation ───────────────────────────────────────────────
165166

166167
class TestStoreIsolation:
167168
@pytest.mark.asyncio
@@ -190,7 +191,7 @@ async def test_logout_without_store_raises_configuration_error(self):
190191

191192
@pytest.mark.asyncio
192193
async def test_no_write_attempted_when_store_missing(self):
193-
"""Fails closed BEFORE any store write never falls back to another store."""
194+
"""Fails closed before any store write, never falls back to another store."""
194195
client = _make_client(anonymous_store=None)
195196
with patch("httpx.AsyncClient") as mock_http:
196197
with pytest.raises(ConfigurationError):
@@ -243,7 +244,7 @@ async def test_create_session_never_attaches_dpop_header(self):
243244

244245
@pytest.mark.asyncio
245246
async def test_create_session_persists_at_distinct_location_from_state_store(self):
246-
"""D3a: the anonymous store instance is separate from any authenticated session store."""
247+
"""The anonymous store instance is separate from any authenticated session store."""
247248
anon_store = OneSlotStore()
248249
state_store = OneSlotStore()
249250
state_store.slot = ("_a0_session", {"user": "authenticated"})
@@ -252,7 +253,7 @@ async def test_create_session_persists_at_distinct_location_from_state_store(sel
252253
with patch("httpx.AsyncClient", fake_http):
253254
await client.create_session(audience="aud", scope="s")
254255
assert anon_store.slot[0] == ANON_IDENTIFIER
255-
# The authenticated session store is a different instance entirely
256+
# The authenticated session store is a different instance entirely,
256257
# never touched by anonymous writes.
257258
assert state_store.slot == ("_a0_session", {"user": "authenticated"})
258259

@@ -337,26 +338,6 @@ async def test_invalid_scope_maps_to_scope_error(self):
337338
with pytest.raises(AnonymousScopeError):
338339
await client.create_session(audience="aud", scope="s")
339340

340-
@pytest.mark.asyncio
341-
async def test_secrets_never_leak_into_cause_even_nested(self):
342-
store = OneSlotStore()
343-
client = _make_client(anonymous_store=store)
344-
fake_http = _FakeAsyncClient([
345-
_fake_response(400, {
346-
"error": "invalid_request",
347-
"error_description": "bad",
348-
"session_token": "LEAKED_TOKEN",
349-
"details": {"client_secret": "LEAKED_SECRET"},
350-
})
351-
])
352-
with patch("httpx.AsyncClient", fake_http):
353-
with pytest.raises(AnonymousResourceServerError) as exc:
354-
await client.create_session(audience="aud", scope="s")
355-
cause_str = str(exc.value.cause)
356-
assert "LEAKED_TOKEN" not in cause_str
357-
assert "LEAKED_SECRET" not in cause_str
358-
assert "[REDACTED]" in cause_str
359-
360341
@pytest.mark.asyncio
361342
async def test_network_failure_raises_create_error(self):
362343
store = OneSlotStore()
@@ -520,7 +501,7 @@ async def test_get_token_never_writes_to_authenticated_state_store(self):
520501
auth_state_store.delete.assert_not_called()
521502

522503

523-
# ── MCD / cross-tenant isolation (B6) ────────────────────────────────────────
504+
# ── MCD / cross-tenant isolation ───────────────────────────────────────────────
524505

525506
class TestMcdIsolation:
526507
@pytest.mark.asyncio
@@ -693,7 +674,7 @@ async def test_returns_none_when_no_session(self):
693674

694675
@pytest.mark.asyncio
695676
async def test_returns_none_never_raises_on_corrupted_token(self):
696-
"""Malformed stored token must deny the link, never abort the caller (D1 §5 step 5)."""
677+
"""Malformed stored token must deny the link, never abort the caller."""
697678
store = OneSlotStore()
698679
store.slot = (ANON_IDENTIFIER, {"context": "garbage"})
699680
client = _make_client(anonymous_store=store)

src/auth0_server_python/tests/test_server_client.py

Lines changed: 18 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8953,17 +8953,16 @@ async def test_complete_interactive_login_milliseconds_ceiling_fails_open(mocker
89538953

89548954

89558955
# =============================================================================
8956-
# ANONYMOUS SESSIONS WIRING AND LOGIN-INJECTION TESTS
8956+
# ANONYMOUS SESSIONS - WIRING AND LOGIN-INJECTION TESTS
89578957
# =============================================================================
89588958

89598959

89608960
class _OneSlotStore:
89618961
"""
8962-
Models StatelessStateStore: a store identifier is used only as an
8963-
encryption salt, not a location key — one physical slot per instance.
8964-
AsyncMock cannot exercise this collision because it treats every
8965-
identifier as a distinct key (see reviews/auth0-server-python/
8966-
store-identifier-location-contract-collision.md).
8962+
Models a store where a store identifier is used only as an encryption
8963+
salt, not a location key. One physical slot per instance. AsyncMock
8964+
cannot exercise this collision because it treats every identifier as a
8965+
distinct key.
89678966
"""
89688967

89698968
def __init__(self):
@@ -9011,7 +9010,7 @@ async def test_server_client_anonymous_property():
90119010

90129011
@pytest.mark.asyncio
90139012
async def test_anonymous_client_receives_own_store_not_state_store():
9014-
"""D3a: the anonymous client must never share the authenticated state store instance."""
9013+
"""The anonymous client must never share the authenticated state store instance."""
90159014
state_store = AsyncMock()
90169015
anon_store = _OneSlotStore()
90179016
client = ServerClient(
@@ -9151,7 +9150,7 @@ def fake_create_url(endpoint, **kwargs):
91519150

91529151
@pytest.mark.asyncio
91539152
async def test_start_interactive_login_malformed_anonymous_token_denies_link_allows_login(mocker):
9154-
"""Undecryptable stored token: deny the link, never abort the login (D1 §5 step 5)."""
9153+
"""Undecryptable stored token: deny the link, never abort the login."""
91559154
anon_store = _OneSlotStore()
91569155
anon_store.slot = (ANON_IDENTIFIER, {"context": "not-a-valid-jwe"})
91579156
client = ServerClient(
@@ -9180,7 +9179,7 @@ async def test_start_interactive_login_malformed_anonymous_token_denies_link_all
91809179

91819180
@pytest.mark.asyncio
91829181
async def test_start_interactive_login_suppresses_injection_on_par_branch(mocker):
9183-
"""PAR is not supported for anonymous sessions — the whole auth_params dict is POSTed there."""
9182+
"""PAR is not supported for anonymous sessions."""
91849183
secret = "a-test-secret-with-enough-length"
91859184
anon_store = _OneSlotStore()
91869185
anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)})
@@ -9233,10 +9232,10 @@ async def post(self, url, **kwargs):
92339232
@pytest.mark.asyncio
92349233
async def test_start_interactive_login_constructor_fixation_blocked_no_active_session():
92359234
"""
9236-
D1 — the exact vector: a caller supplies session_token via constructor
9237-
authorization_params, with NO active anonymous session. INTERNAL_AUTHORIZE_PARAMS
9238-
alone cannot block this (it only filters per-call options.authorization_params);
9239-
the unconditional pop() at the injection site must.
9235+
The exact vector where a caller supplies session_token via constructor
9236+
authorization_params, with no active anonymous session. INTERNAL_AUTHORIZE_PARAMS
9237+
alone cannot block this, since it only filters per-call options.authorization_params.
9238+
The unconditional pop() at the injection site must.
92409239
"""
92419240
assert "session_token" in INTERNAL_AUTHORIZE_PARAMS # belt-and-braces still present
92429241

@@ -9337,15 +9336,15 @@ def fake_create_url(endpoint, **kwargs):
93379336
assert captured.get("session_token") == "ANON_TOKEN_1"
93389337

93399338

9340-
# ── Store-collision regression (D3a / B7 / tracker §7.6) ────────────────────
9339+
# ── Store-collision regression ─────────────────────────────────────────────────
93419340

93429341

93439342
@pytest.mark.asyncio
93449343
async def test_anonymous_write_cannot_destroy_authenticated_session_on_shared_store():
93459344
"""
9346-
D3a proof: when the anonymous client is configured with its OWN store
9347-
instance (as constructed), the authenticated session on a separate store
9348-
instance is provably untouched — the separate-instance contract holds.
9345+
When the anonymous client is configured with its own store instance,
9346+
the authenticated session on a separate store instance is provably
9347+
untouched. The separate-instance contract holds.
93499348
"""
93509349
shared_store = _OneSlotStore()
93519350
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
93759374
async def test_missing_anonymous_store_fails_closed_never_falls_back_to_state_store():
93769375
"""
93779376
If an integrator forgets anonymous_store, the client must raise before any
9378-
write — never silently write anonymous state into ServerClient's state_store
9379-
(which is exactly the collision D3a prevents).
9377+
write, never silently write anonymous state into ServerClient's state_store.
93809378
"""
93819379
shared_store = _OneSlotStore()
93829380
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
93979395

93989396
@pytest.mark.asyncio
93999397
async def test_get_session_and_get_user_unaffected_by_active_anonymous_session():
9400-
"""Anonymous state never touches _a0_session get_session()/get_user() see no new keys."""
9398+
"""Anonymous state never touches _a0_session. get_session()/get_user() see no new keys."""
94019399
secret = "a-test-secret-with-enough-length"
94029400
anon_store = _OneSlotStore()
94039401
anon_store.slot = (ANON_IDENTIFIER, {"context": _make_anon_context(secret)})

src/auth0_server_python/utils/helpers.py

Lines changed: 0 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -399,33 +399,3 @@ def validate_org_claims(claims: dict, expected_org: str) -> None:
399399
raise OrganizationTokenValidationError(
400400
"Organization Name (org_name) claim value mismatch in the ID token"
401401
)
402-
403-
404-
# =============================================================================
405-
# Secret Redaction
406-
# =============================================================================
407-
408-
_SECRET_FIELDS = frozenset({
409-
"client_secret",
410-
"session_token",
411-
"access_token",
412-
"assertion",
413-
"client_assertion",
414-
})
415-
416-
417-
def scrub_secrets(data: Any) -> Any:
418-
"""
419-
Recursively redact Tier 0/1 secret fields from a parsed error body.
420-
421-
Walks dicts and lists so a secret nested inside a sub-object (e.g.
422-
{"details": {"session_token": "..."}}) is caught, not just top-level keys.
423-
"""
424-
if isinstance(data, dict):
425-
return {
426-
key: "[REDACTED]" if key in _SECRET_FIELDS else scrub_secrets(value)
427-
for key, value in data.items()
428-
}
429-
if isinstance(data, list):
430-
return [scrub_secrets(item) for item in data]
431-
return data

0 commit comments

Comments
 (0)