Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ async def callback(request: Request):

The SDK supports [Auth0 Organizations](https://auth0.com/docs/organizations) with first-class `organization` and `invitation` parameters on `ServerClient` and `StartInteractiveLoginOptions`. Token claim validation is enforced automatically at callback. For dedicated-org and multi-org patterns, invitation flows, error handling, and reading org data from the session, see [examples/OrganizationLogin.md](examples/OrganizationLogin.md).

#### Experiment Center Overrides

[Auth0 Experiment Center](https://auth0.com/docs/customize/experiment-center/overview) runs A/B tests on your login flows and assigns each user to a variation automatically. To force a specific variation for a single login, pass `experiment_id`, `variation_id`, and optionally `segment_id` as authorization params on the `start_interactive_login()` call. Experiment Center is an Enterprise feature. For per-call usage and the segment-targeting variant, see [examples/InteractiveLogin.md](examples/InteractiveLogin.md#experiment-center-overrides).

### 4. Login with Custom Token Exchange

If you're migrating from a legacy authentication system or integrating with a custom identity provider, you can exchange external tokens for Auth0 tokens using the OAuth 2.0 Token Exchange specification (RFC 8693):
Expand Down
33 changes: 33 additions & 0 deletions examples/InteractiveLogin.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,39 @@ authorization_url = await server_client.start_interactive_login({
> [!NOTE]
> Any parameter specified here will override the corresponding global configuration.

### Experiment Center Overrides
Auth0 Experiment Center runs A/B tests on your login flows, and by default Auth0 assigns each user to a variation automatically. To force a specific variation for a single login (for example while reproducing a variation during debugging), pass `experiment_id` and `variation_id` as authorization params on the login call. Both IDs come from your Auth0 Dashboard or the Management API:
```python
Comment thread
kishore7snehil marked this conversation as resolved.
from auth0_server_python.auth_types import StartInteractiveLoginOptions

authorization_url = await server_client.start_interactive_login(
StartInteractiveLoginOptions(
authorization_params={
"experiment_id": "exp_123",
"variation_id": "var_456",
}
)
)
```
When the experiment uses segment targeting, also pass `segment_id`:
```python
authorization_url = await server_client.start_interactive_login(
StartInteractiveLoginOptions(
authorization_params={
"experiment_id": "exp_123",
"variation_id": "var_456",
"segment_id": "seg_789",
}
)
)
```
The override applies to this login request only.

> [!IMPORTANT]
> Pass these per call, not in the client-level `authorization_params` at construction. A construction-time value pins every login to the same variation and defeats the experiment.
>
> Experiment Center is an Enterprise feature. Refer to the [Experiment Center documentation](https://auth0.com/docs/customize/experiment-center/overview) for more information and setup.

## 3. Passing App State to Track State During Login

The `app_state` parameter allows you to pass custom state (for example, a return URL) that is later available when the login process completes.
Expand Down
6 changes: 6 additions & 0 deletions src/auth0_server_python/auth_types/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,12 @@ class AuthorizationParameters(BaseModel):
scope: Optional[str] = None
audience: Optional[str] = None
redirect_uri: Optional[str] = None
# Auth0 Experiment Center (A/B testing) override params, applied to this request only.
# Pass any of them to hint a specific experiment, variation, or segment instead of the
# automatic assignment. These are optional override hints, not a strict contract.
experiment_id: Optional[str] = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One design thing worth noting. This model defines the params as typed fields, but the login path reads authorization_params as a plain dict, so the typed fields here don't actually flow into the call path or give type checking at the call site. I see you already called this out in the PR description as a known gap, so I'm not asking to change it in this PR.

Is dict the intended shape for now?

@nandan-bhat nandan-bhat Sep 29, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, dict is what we want for now.

The typed fields on AuthorizationParameters are only there for parity and to help discovery.
The login path still reads authorization_params as a plain dict. I didn't want to change this pattern in this PR as this is a pre-existing pattern in this SDK.

variation_id: Optional[str] = None
segment_id: Optional[str] = None

class Config:
extra = "allow" # Allow additional OAuth parameters
Expand Down
159 changes: 159 additions & 0 deletions src/auth0_server_python/tests/test_server_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,165 @@ async def test_start_interactive_login_builds_auth_url(mocker):
mock_oauth.assert_called_once()


@pytest.mark.asyncio
async def test_start_interactive_login_forwards_experiment_center_params(mocker):
Comment thread
kishore7snehil marked this conversation as resolved.
"""Experiment Center override params passed per call reach the /authorize request."""
client = ServerClient(
domain="auth0.local",
client_id="<client_id>",
client_secret="<client_secret>",
state_store=AsyncMock(),
transaction_store=AsyncMock(),
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"},
)
mock_oauth = mocker.patch.object(
client._oauth,
"create_authorization_url",
return_value=("https://auth0.local/authorize", "some_state"),
)

await client.start_interactive_login(
StartInteractiveLoginOptions(
authorization_params={
"experiment_id": "exp_123",
"variation_id": "var_456",
"segment_id": "seg_789",
}
)
)

# EC params are not in INTERNAL_AUTHORIZE_PARAMS, so they flow through to /authorize.
forwarded = mock_oauth.call_args.kwargs
Comment thread
kishore7snehil marked this conversation as resolved.
assert forwarded["experiment_id"] == "exp_123"
assert forwarded["variation_id"] == "var_456"
assert forwarded["segment_id"] == "seg_789"


@pytest.mark.asyncio
async def test_start_interactive_login_omits_unset_experiment_center_params(mocker):
"""An Experiment Center param that is not passed never reaches the /authorize request."""
client = ServerClient(
domain="auth0.local",
client_id="<client_id>",
client_secret="<client_secret>",
state_store=AsyncMock(),
transaction_store=AsyncMock(),
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"},
)
mock_oauth = mocker.patch.object(
client._oauth,
"create_authorization_url",
return_value=("https://auth0.local/authorize", "some_state"),
)

await client.start_interactive_login(
StartInteractiveLoginOptions(
authorization_params={
"experiment_id": "exp_123",
"variation_id": "var_456",
}
)
)

# segment_id was not passed, so it must not be forwarded to /authorize.
forwarded = mock_oauth.call_args.kwargs
assert forwarded["experiment_id"] == "exp_123"
assert forwarded["variation_id"] == "var_456"
assert "segment_id" not in forwarded


@pytest.mark.asyncio
async def test_start_interactive_login_forwards_experiment_center_params_via_par(mocker):
"""On the PAR branch, Experiment Center override params are posted in the PAR request body."""
client = ServerClient(
domain="auth0.local",
client_id="my_client",
client_secret="my_secret",
state_store=AsyncMock(),
transaction_store=AsyncMock(),
secret="some-secret",
pushed_authorization_requests=True,
authorization_params={"redirect_uri": "/test_redirect_uri"},
)
mocker.patch.object(
client,
"_get_oidc_metadata_cached",
return_value={
"issuer": "https://auth0.local/",
"authorization_endpoint": "https://auth0.local/authorize",
"pushed_authorization_request_endpoint": "https://auth0.local/oauth/par",
},
)
mock_post = mocker.patch("httpx.AsyncClient.post", new_callable=AsyncMock)
par_response = AsyncMock()
par_response.status_code = 201
par_response.json = MagicMock(return_value={"request_uri": "urn:req:abc", "expires_in": 60})
mock_post.return_value = par_response

await client.start_interactive_login(
StartInteractiveLoginOptions(
authorization_params={
"experiment_id": "exp_123",
"variation_id": "var_456",
"segment_id": "seg_789",
}
)
)

# EC params are not in INTERNAL_AUTHORIZE_PARAMS, so they flow through to the PAR body.
posted = mock_post.call_args[1]["data"]
assert posted["experiment_id"] == "exp_123"
assert posted["variation_id"] == "var_456"
assert posted["segment_id"] == "seg_789"


@pytest.mark.asyncio
async def test_start_interactive_login_experiment_center_params_appear_in_url(mocker):
"""The EC override params show up in the query string of the built authorization URL."""
client = ServerClient(
domain="auth0.local",
client_id="<client_id>",
client_secret="<client_secret>",
state_store=AsyncMock(),
transaction_store=AsyncMock(),
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"},
)
# No builder mock here, so authlib builds the real URL and we can check the query string.

url = await client.start_interactive_login(
StartInteractiveLoginOptions(
authorization_params={
"experiment_id": "exp_123",
"variation_id": "var_456",
"segment_id": "seg_789",
}
)
)

query = parse_qs(urlparse(url).query)
assert query["experiment_id"] == ["exp_123"]
assert query["variation_id"] == ["var_456"]
assert query["segment_id"] == ["seg_789"]


@pytest.mark.asyncio
async def test_par_request_uses_private_key_jwt_assertion(mocker):
"""The pushed authorization request posts a client assertion when a signing key is set."""
Expand Down
Loading