Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
75 commits
Select commit Hold shift + click to select a range
796c9ed
Added Anonymous Session Support Changes
rmad17 Aug 11, 2026
a2a0afe
docs: clean up, formatting improvement and docs content update
rmad17 Aug 13, 2026
4c1a173
chore: renaming of classes to bring consistency, adding fallback with…
rmad17 Aug 13, 2026
118b25d
chore: trimmed comments
rmad17 Aug 14, 2026
5d6f511
docs: Optimized example docs
rmad17 Aug 14, 2026
c5d5cff
docs: Updated docs to remove semicolon
rmad17 Aug 14, 2026
29e6e0f
Merge branch 'main' into feat/anonymous-sessions
rmad17 Aug 14, 2026
cfb000a
fix: rename Anonymous Session errors to add Session in the error name…
rmad17 Aug 19, 2026
ee70a38
fix: Resolved logout error correctly
rmad17 Aug 19, 2026
3b25bcf
fix: make AnonymousClient domain typed
rmad17 Aug 19, 2026
307ba7d
fix: widened metadata types
rmad17 Aug 19, 2026
c3e6fd9
fix: session_token, sub, session_id in _remint now use explicit is no…
rmad17 Aug 19, 2026
6304af8
fix: seperate response structures for session token in create and remint
rmad17 Aug 20, 2026
edeb3d1
test: extract shared OneSlotStore fake into store_fakes
rmad17 Aug 20, 2026
bc40a56
fix: run anonymous session domain-mismatch check in static mode too
rmad17 Aug 20, 2026
15e4732
test: cover corrupted anonymous context in introspect and logout
rmad17 Aug 20, 2026
61d727e
fix: drop sub,session_id from anonymous sessions response, fix logout…
rmad17 Aug 21, 2026
ead816f
fix: remove is_new from AnonymousSession, unused signal never in spec
rmad17 Aug 23, 2026
090b798
docs: remove is_new from AnonymousSession field list
rmad17 Aug 23, 2026
9364261
Merge branch 'main' into feat/anonymous-sessions
rmad17 Aug 25, 2026
41fa7c4
Merge branch 'main' into feat/anonymous-sessions
rmad17 Aug 26, 2026
4e6c33f
Merge branch 'main' into feat/anonymous-sessions
rmad17 Sep 1, 2026
ce4c289
Merge branch 'main' into feat/anonymous-sessions
rmad17 Sep 17, 2026
ea0575b
feat: carry anonymous session to login via short-lived transfer ticket
rmad17 Sep 23, 2026
fa0b429
feat: carry anonymous session to login via short-lived transfer ticket
rmad17 Sep 23, 2026
1e055a8
Merge branch 'feat/anonymous-sessions' of github.com:auth0/auth0-serv…
rmad17 Sep 23, 2026
fa97f63
Merge branch 'feat/anonymous-sessions-2' into feat/anonymous-sessions
rmad17 Sep 23, 2026
8a3274f
feat: harden anonymous sessions with sub, get_session, and review fixes
rmad17 Sep 23, 2026
5e6be62
fix: use parsed-host comparisons in anonymous session URL test assert…
rmad17 Sep 24, 2026
d101cec
refactor: move _end_session_if_active out of AnonymousClient public A…
rmad17 Sep 24, 2026
1985971
docs: fix stale logout/login-injection claims and trim anonymous sess…
rmad17 Sep 24, 2026
11ec44d
refactor: fix docstring format and move anonymous property to end of …
rmad17 Sep 24, 2026
8d26ea2
docs: remove dashes, semicolon splices, and narrating comments per re…
rmad17 Sep 24, 2026
dce5efa
fix: remove introspect() and its unimplemented /anonymous/userinfo en…
rmad17 Sep 24, 2026
bc59c3b
feat: default clear_anonymous_session_on_login to True
rmad17 Sep 25, 2026
86f8f7a
docs: remove fail-open/fail-closed/best-effort labels and comment colons
rmad17 Sep 25, 2026
e1e6c68
refactor: trim two overlong test docstrings
rmad17 Sep 25, 2026
3b4b672
refactor: align anonymous session error hierarchy with spec
rmad17 Sep 25, 2026
84b2472
docs: remove remaining fail-closed label from AnonymousSessionContext…
rmad17 Sep 25, 2026
6986c1e
fix: raise on decrypt failure in get_token instead of silently re-cre…
rmad17 Sep 29, 2026
b127b68
fix: tolerate missing session_expires_in for legacy anonymous tokens
rmad17 Sep 29, 2026
d4d8f18
feat: surface granted scope in AnonymousSession from platform token r…
rmad17 Sep 29, 2026
36fb8ac
fix: fail closed on null stored domain in resolver mode for domain guard
rmad17 Sep 29, 2026
1f3c9ce
fix: wire clear_anonymous_session_on_login to backchannel, passkey, a…
rmad17 Sep 29, 2026
d575aae
feat: support private_key_jwt authentication for anonymous session op…
rmad17 Sep 29, 2026
0cd4f2b
fix: do not forward metadata on MCD domain mismatch re-mint
rmad17 Sep 29, 2026
c95980a
fix: remove session_token from AnonymousSession public model
rmad17 Sep 29, 2026
b737711
fix: correct base64 padding formula in _decode_sub
rmad17 Sep 29, 2026
92f9827
test: strengthen silent-create assertion on session_expired path
rmad17 Sep 29, 2026
76509e7
fix: reparent _AnonymousSessionExpired under AnonymousSessionError
rmad17 Sep 29, 2026
38fd3fc
fix: validate anon@ sub shape in _decode_sub, remove JS-only prototyp…
rmad17 Sep 29, 2026
c09c7e9
fix: HTTP timeout, token freshness leeway, concurrency write guard, g…
rmad17 Sep 29, 2026
f3a7bdf
test: add sub backfill test; fix stale session_token and dangerous-ke…
rmad17 Sep 29, 2026
d404a38
test: cover concurrent re-read decrypt failure and get_session resolv…
rmad17 Sep 29, 2026
0c5e37c
docs: document anon error hierarchy, codes, and AnonymousSessionToken…
rmad17 Sep 29, 2026
3da7b58
style: replace semicolons with plain connectors in test docstrings an…
rmad17 Sep 29, 2026
3161b9a
fix: address PR review comments on anonymous sessions
rmad17 Sep 30, 2026
28bf136
style: remove trailing whitespace from blank lines in test_anonymous_…
rmad17 Sep 30, 2026
f93538a
fix: preserve metadata on silent session re-creation when session tok…
rmad17 Sep 30, 2026
9761792
refactor: split anonymous_client into anonymous/ folder with client a…
rmad17 Sep 30, 2026
29dc6c2
docs: fix stale encryption wording and clarify store encryption respo…
rmad17 Sep 30, 2026
96d087a
refactor: fix repo convention violations in anonymous client and helpers
rmad17 Sep 30, 2026
924a1f6
docs: update anonymous sessions flow-map entry for new module structure
rmad17 Sep 30, 2026
4691b16
Merge branch 'main' into feat/anonymous-sessions
nandan-bhat Sep 30, 2026
271f7e3
fix: clear anonymous session after passwordless, MFA first-login, and…
rmad17 Sep 30, 2026
2de2d2b
fix: remove logger.debug from anonymous session cleanup paths
rmad17 Oct 1, 2026
3660c64
docs: add anonymous store implementation example to AnonymousSessions…
rmad17 Oct 1, 2026
105a813
fix: add explanatory comments to anonymous session cleanup except cla…
rmad17 Oct 1, 2026
8df0b49
fix: remove Enterprise Connect guard from anonymous session injection
rmad17 Oct 1, 2026
6a6278d
fix: reject NaN/Infinity metadata, add missing asyncio markers, fix d…
rmad17 Oct 1, 2026
761b94e
feat: raise session_expired error on session token expiry instead of …
rmad17 Oct 1, 2026
503a29a
docs: document anonymous get_session and fix configuration-error note
rmad17 Oct 1, 2026
981b642
refactor: group anonymous transfer-token helpers before public method
rmad17 Oct 1, 2026
974a524
docs: document clear_anonymous_session_on_login in AnonymousSessions …
rmad17 Oct 1, 2026
f127590
fix: clear anonymous session on session-token expiry during remint
rmad17 Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ test-script.py
coverage.xml

# AI tools
.claude
.claude
.worktrees
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,11 @@ Bind tokens to a key your server holds ([RFC 9449](https://www.rfc-editor.org/rf

Sign users in with a one-time code sent by email or SMS, or with a magic link sent by email, via [Auth0 embedded passwordless login](https://auth0.com/docs/authenticate/passwordless/implement-login/embedded-login/relevant-api-endpoints). OTP verification and the magic-link callback each establish a server-side session like every other login path. For prerequisites, both flows, custom scopes/audiences, step-up MFA, and error handling, see [examples/Passwordless.md](examples/Passwordless.md).

### 11. Enterprise Connect (Embedded Login)
### 11. Anonymous Sessions

Give a visitor an Auth0 `anon@<uuid>` 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)

Sign users in through their company's identity provider while **your application owns the session**. Opt in with `enterprise_connect=True`; Auth0 acts as a pure SSO relay and issues no refresh token. `start_enterprise_login()` discovers whether an email domain is managed and returns an authorization URL or `None`, and `complete_interactive_login()` returns the verified claims and access token for your app to build its own session from. Early Access. For discovery, the callback contract, multi-tenant `org_id` checks, and federated logout, see [examples/EnterpriseConnect.md](examples/EnterpriseConnect.md).

Expand Down
192 changes: 192 additions & 0 deletions examples/AnonymousSessions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
# Anonymous Sessions

Anonymous Sessions give a visitor an Auth0 identity **before they log in**. Each visitor gets a persistent `anon@<uuid>` 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

- [Anonymous Sessions](#anonymous-sessions)
- [Table of Contents](#table-of-contents)
- [Setup](#setup)
- [Creating a Session](#creating-a-session)
- [Getting a Token (Renewal Ladder)](#getting-a-token-renewal-ladder)
- [Reading the Session](#reading-the-session)
- [Logging Out](#logging-out)
- [Login Injection](#login-injection)
- [Rate-Limiting `get_token()`](#rate-limiting-get_token)
- [Error Handling](#error-handling)
- [Additional Resources](#additional-resources)

## Setup

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`:

```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, # its own store instance, not state_store
)
```

Give `anonymous_store` its own store instance, not `state_store` with a different identifier. If you omit it, `create_session()`, `get_token()`, and `logout()` raise `ConfigurationError` before any write. `get_session()` returns `None`.

The anonymous store implements the same `StateStore` ABC as your existing session store. A minimal cookie-backed example:

```python
from auth0_server_python.store import StateStore

class AnonymousSessionStore(StateStore):
def __init__(self, secret: str):
super().__init__({"secret": secret})

async def set(self, identifier, state, remove_if_expires=False, options=None):
# Encrypt and write to a cookie or server-side store.
# The SDK passes a plain dict; encryption is your responsibility.
encrypted = self.encrypt(identifier, state)
options["response"].set_cookie("_a0_anon", encrypted, httponly=True, samesite="lax")

async def get(self, identifier, options=None):
value = options["request"].cookies.get("_a0_anon")
if not value:
return None
return self.decrypt(identifier, value)

async def delete(self, identifier, options=None):
options["response"].delete_cookie("_a0_anon")
```

`self.encrypt` / `self.decrypt` are helpers from the `StateStore` base class that derive a key from your `secret` and the store identifier. The cookie name `_a0_anon` must be distinct from the cookie name used by your `state_store` - a shared name will silently overwrite the authenticated session. See `examples/ConfigureStore.md` for the full store configuration reference.

> [!IMPORTANT]
> **Encryption is your responsibility.** The SDK writes the anonymous session as a plain dict with no encryption applied. If your `anonymous_store` is cookie-backed or otherwise persists data outside a trusted server boundary, you must encrypt the payload before writing and decrypt it on read. This is the same responsibility you already have for `state_store`.

## 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**. 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 `access_token`, `expires_at`, `session_expires_at`, `metadata`, `sub`, and `scope`.

## Getting a Token

```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, since anonymous sessions never issue refresh tokens).
3. Session token also expired or invalid, raises `AnonymousSessionTokenError` with code `session_expired` and clears the stored session. Call `create_session()` to start a new session.
4. Any other error, raised as a typed exception. No swallow, no auto-retry.

> [!IMPORTANT]
> **On `session_expired`, the previous anonymous identity is gone.** The SDK clears the stored session before raising, so a subsequent `get_session()` returns `None`. This release does not surface the expired identity (its `sub` or `metadata`) on the error. Call `create_session()` to start fresh.
>
> Do any identity-dependent work, such as cart or data migration, at **login time**, not on expiry. Read `get_session().sub` before calling `complete_interactive_login()`. That is the normal migration path and is unaffected by the expiry clear. A session expiring before the visitor ever logs in is rare given the session lifetime, and the correct response is simply to create a new one.

## Reading the Session

```python
data = await server_client.anonymous.get_session(store_options=store_options)
```

Returns the stored anonymous identity without calling Auth0, or `None` when there is no session, the stored record is unreadable, or the stored domain does not match the current tenant. Use it to check whether a visitor already has an anonymous identity before deciding to call `create_session()`.

`AnonymousSessionData` carries `sub`, `metadata`, `created_at`, `session_expires_at`, and `domain` - the identity fields only. It never exposes the session token or an access token. To obtain a usable access token, call `get_token()`.

Unlike the other `.anonymous.*` methods, `get_session()` returns `None` rather than raising when no `anonymous_store` is configured.

## 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 session 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.

## Login Injection

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) 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 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.

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.

After `complete_interactive_login` succeeds, the SDK clears the anonymous session by default. To keep it active across the login boundary:

```python
server_client = ServerClient(
...
clear_anonymous_session_on_login=False,
)
```

## Rate-Limiting `get_token()`

`get_token()` makes at most one upstream Auth0 call 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 `AnonymousSessionError`, carrying a `.code` you can branch on:

```python
from auth0_server_python.error import (
AnonymousSessionCreateError,
AnonymousSessionTokenError,
)

try:
session = await server_client.anonymous.create_session(audience="...", scope="...")
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 (server-returned code, passed through unchanged) |
| `"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"`) and these are passed through on `.code` unchanged.

### `AnonymousSessionTokenError` codes

| `.code` | When |
|---------|------|
| `"session_expired"` | session token has expired or been invalidated - call `create_session()` to start a new session |
| `"invalid_session_state"` | stored session data is corrupt or unreadable - 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.
1 change: 1 addition & 0 deletions references/flow-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. `start_interactive_login` injects an `anon_transfer_token` via `AnonymousClient.exchange_transfer_token_for_injection` | `auth_server/anonymous/client.py`, `auth_server/anonymous/helpers.py`, `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
Expand Down
2 changes: 2 additions & 0 deletions src/auth0_server_python/auth_server/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .anonymous import AnonymousClient
from .mfa_client import MfaClient
from .my_account_client import MyAccountClient
from .passwordless_client import PasswordlessClient
Expand All @@ -7,6 +8,7 @@
"ServerClient",
"MyAccountClient",
"MfaClient",
"AnonymousClient",
"PasswordlessClient",
"is_federated_domain",
]
4 changes: 4 additions & 0 deletions src/auth0_server_python/auth_server/anonymous/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from auth0_server_python.auth_server.anonymous.client import AnonymousClient
from auth0_server_python.auth_server.anonymous.helpers import ANON_IDENTIFIER

__all__ = ["AnonymousClient", "ANON_IDENTIFIER"]
Loading
Loading