From 51c72ad9025ba66b733a2460f3bb51f311dfa41a Mon Sep 17 00:00:00 2001 From: Nandan Bhat Date: Tue, 29 Sep 2026 00:38:44 +0530 Subject: [PATCH 1/4] feat: add Experiment Center override params to AuthorizationParameters --- examples/InteractiveLogin.md | 20 ++++++++++ .../auth_types/__init__.py | 5 +++ .../tests/test_server_client.py | 40 +++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/examples/InteractiveLogin.md b/examples/InteractiveLogin.md index 02b359d..73dd684 100644 --- a/examples/InteractiveLogin.md +++ b/examples/InteractiveLogin.md @@ -61,6 +61,26 @@ 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 experiment, variation, or segment (for example while reproducing a variation during debugging), pass `experiment_id`, `variation_id`, and `segment_id` as authorization params on the login call: +```python +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", + "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. + ## 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. diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index d419915..aad1a41 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -245,6 +245,11 @@ class AuthorizationParameters(BaseModel): scope: Optional[str] = None audience: Optional[str] = None redirect_uri: Optional[str] = None + # Auth0 Experiment Center (A/B testing) override params. Pass these to force a + # specific experiment, variation, or segment instead of the automatic assignment. + experiment_id: Optional[str] = None + variation_id: Optional[str] = None + segment_id: Optional[str] = None class Config: extra = "allow" # Allow additional OAuth parameters diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 77b43f5..119d695 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -177,6 +177,46 @@ 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): + """Experiment Center override params passed per call reach the /authorize request.""" + client = ServerClient( + domain="auth0.local", + client_id="", + 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 + assert forwarded["experiment_id"] == "exp_123" + assert forwarded["variation_id"] == "var_456" + assert forwarded["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.""" From b074d1569c4f3d3de24cbf38f2d22f474f26945b Mon Sep 17 00:00:00 2001 From: Nandan Bhat Date: Tue, 29 Sep 2026 01:04:52 +0530 Subject: [PATCH 2/4] review: address self review findings --- examples/InteractiveLogin.md | 15 ++++++- .../auth_types/__init__.py | 4 +- .../tests/test_server_client.py | 39 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/examples/InteractiveLogin.md b/examples/InteractiveLogin.md index 73dd684..7e734f5 100644 --- a/examples/InteractiveLogin.md +++ b/examples/InteractiveLogin.md @@ -62,10 +62,21 @@ authorization_url = await server_client.start_interactive_login({ > 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 experiment, variation, or segment (for example while reproducing a variation during debugging), pass `experiment_id`, `variation_id`, and `segment_id` as authorization params on the login call: +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 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={ @@ -80,6 +91,8 @@ 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 diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index aad1a41..9509439 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -246,7 +246,9 @@ class AuthorizationParameters(BaseModel): audience: Optional[str] = None redirect_uri: Optional[str] = None # Auth0 Experiment Center (A/B testing) override params. Pass these to force a - # specific experiment, variation, or segment instead of the automatic assignment. + # specific variation instead of the automatic assignment, for this request only. + # variation_id and segment_id both require experiment_id; segment_id applies only + # to segment-targeted experiments. experiment_id: Optional[str] = None variation_id: Optional[str] = None segment_id: Optional[str] = None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 119d695..4778e85 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -217,6 +217,45 @@ async def test_start_interactive_login_forwards_experiment_center_params(mocker) 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_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_par_request_uses_private_key_jwt_assertion(mocker): """The pushed authorization request posts a client assertion when a signing key is set.""" From f2eb2f6c1285343e40524ed592c2ad9fce35f8b0 Mon Sep 17 00:00:00 2001 From: Nandan Bhat Date: Tue, 29 Sep 2026 15:33:12 +0530 Subject: [PATCH 3/4] review: addressing peer review feedbacks --- README.md | 4 ++ .../auth_types/__init__.py | 7 ++- .../tests/test_server_client.py | 45 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index f1778e5..b62b19e 100644 --- a/README.md +++ b/README.md @@ -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): diff --git a/src/auth0_server_python/auth_types/__init__.py b/src/auth0_server_python/auth_types/__init__.py index 9509439..368b1b2 100644 --- a/src/auth0_server_python/auth_types/__init__.py +++ b/src/auth0_server_python/auth_types/__init__.py @@ -245,10 +245,9 @@ class AuthorizationParameters(BaseModel): scope: Optional[str] = None audience: Optional[str] = None redirect_uri: Optional[str] = None - # Auth0 Experiment Center (A/B testing) override params. Pass these to force a - # specific variation instead of the automatic assignment, for this request only. - # variation_id and segment_id both require experiment_id; segment_id applies only - # to segment-targeted experiments. + # 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 variation_id: Optional[str] = None segment_id: Optional[str] = None diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index b3b6cdb..05f93ea 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -256,6 +256,51 @@ async def test_start_interactive_login_omits_unset_experiment_center_params(mock 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_par_request_uses_private_key_jwt_assertion(mocker): """The pushed authorization request posts a client assertion when a signing key is set.""" From fcff10777f6faeca4f9db2f7887b7622f9a98930 Mon Sep 17 00:00:00 2001 From: Nandan Bhat Date: Tue, 29 Sep 2026 16:18:12 +0530 Subject: [PATCH 4/4] test: cover Experiment Center param forwarding on PAR and URL paths --- .../tests/test_server_client.py | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/auth0_server_python/tests/test_server_client.py b/src/auth0_server_python/tests/test_server_client.py index 05f93ea..c332bca 100644 --- a/src/auth0_server_python/tests/test_server_client.py +++ b/src/auth0_server_python/tests/test_server_client.py @@ -301,6 +301,41 @@ async def test_start_interactive_login_forwards_experiment_center_params_via_par 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_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."""