From b8d245dec62a0223af730812730ece549ae4072a Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 11 Aug 2026 11:58:02 +0530 Subject: [PATCH 01/18] CTE Org Support Wrapper --- examples/CustomTokenExchange.md | 36 ++++++++++-- src/auth0_fastapi/auth/auth_client.py | 6 ++ src/auth0_fastapi/errors/__init__.py | 3 + src/auth0_fastapi/test/test_auth_client.py | 65 +++++++++++++++++++++- 4 files changed, 105 insertions(+), 5 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index cdffc87..4ac852f 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -102,9 +102,28 @@ async def get_profile(session: dict = Depends(auth_client.require_session)): > **TIP**: Use `login_with_custom_token_exchange()` for user-migration or external-IdP login flows. Use `custom_token_exchange()` for pure service-to-service or downstream-API scenarios where the caller's own session should not change. -## 3. Error Handling +## 3. Scoping the Exchange to an Organization -Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP `400` JSON response automatically: +Pass `organization` (an org ID like `org_abc123`, or an org name) to scope the exchange to a specific +Auth0 Organization. The returned token's `org_id`/`org_name` claim is verified against the value you +passed — if the token comes back scoped to a different organization, or carries no ID token to check +at all, the exchange raises `OrganizationTokenValidationError` instead of returning a token silently +scoped to the wrong tenant: + +```python +result = await auth_client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="token-from-external-idp", + subject_token_type="urn:acme:corporate-idp-token", + organization="org_abc123", + ), + store_options={"request": request, "response": response}, +) +``` + +## 4. Error Handling + +Register the SDK's exception handler once, and `CustomTokenExchangeError` / `OrganizationTokenValidationError` will be mapped to HTTP JSON responses automatically: ```python from auth0_fastapi.errors import register_exception_handlers @@ -116,7 +135,11 @@ Or handle it inline if a route needs custom behavior: ```python from fastapi import HTTPException -from auth0_fastapi.errors import CustomTokenExchangeError, CustomTokenExchangeErrorCode +from auth0_fastapi.errors import ( + CustomTokenExchangeError, + CustomTokenExchangeErrorCode, + OrganizationTokenValidationError, +) @router.post("/api/exchange") async def exchange_token(request: Request, response: Response): @@ -125,9 +148,12 @@ async def exchange_token(request: Request, response: Response): CustomTokenExchangeOptions( subject_token=request.headers.get("x-external-token", ""), subject_token_type="urn:acme:legacy-session-token", + organization="org_abc123", ), store_options={"request": request, "response": response}, ) + except OrganizationTokenValidationError: + raise HTTPException(status_code=401, detail="Token was not issued for the requested organization") except CustomTokenExchangeError as e: if e.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT: raise HTTPException(status_code=400, detail="Invalid subject token") @@ -142,7 +168,9 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 `INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -## 4. Token Type URIs +`OrganizationTokenValidationError` is raised after the exchange completes, only when `organization` was passed: if the returned token has no ID token to verify, or its `org_id`/`org_name` claim doesn't match the requested organization. + +## 5. Token Type URIs `subject_token_type` accepts any URI — a standard RFC 8693 URN (e.g. `urn:ietf:params:oauth:token-type:jwt`) or your own namespace (e.g. `urn:acme:legacy-session-token`). diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index f9468a3..f07f339 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -247,6 +247,9 @@ async def custom_token_exchange( Raises: CustomTokenExchangeError: If the exchange fails or the subject token parameters are invalid (see CustomTokenExchangeErrorCode). + OrganizationTokenValidationError: If organization was requested + and the returned token does not carry a matching org_id/org_name + claim, or carries no ID token to verify it against. """ return await self.client.custom_token_exchange(options, store_options=store_options) @@ -274,6 +277,9 @@ async def login_with_custom_token_exchange( Raises: CustomTokenExchangeError: If the exchange fails or the subject token parameters are invalid (see CustomTokenExchangeErrorCode). + OrganizationTokenValidationError: If organization was requested + and the returned token does not carry a matching org_id/org_name + claim, or carries no ID token to verify it against. ValueError: If store_options is missing the response needed to write the session cookie. """ diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 0e82368..be3cae9 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -10,6 +10,7 @@ IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, + OrganizationTokenValidationError, ) from fastapi import Request from fastapi.responses import JSONResponse @@ -39,6 +40,8 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): status_code = 422 # Unprocessable Entity elif isinstance(exc, IssuerValidationError): status_code = 401 # Unauthorized - token issuer mismatch + elif isinstance(exc, OrganizationTokenValidationError): + status_code = 401 # Unauthorized - organization claim mismatch elif isinstance(exc, ApiError): status_code = 502 # Bad Gateway, indicates an upstream error elif isinstance(exc, AccessTokenError): diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 8d028c7..febd1ea 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -10,7 +10,11 @@ LoginWithCustomTokenExchangeResult, TokenExchangeResponse, ) -from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode +from auth0_server_python.error import ( + CustomTokenExchangeError, + CustomTokenExchangeErrorCode, + OrganizationTokenValidationError, +) from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -694,6 +698,41 @@ async def test_custom_token_exchange_error_propagates(self, auth_client): with pytest.raises(CustomTokenExchangeError): await auth_client.custom_token_exchange(options) + @pytest.mark.asyncio + async def test_custom_token_exchange_organization_forwarded(self, auth_client): + """Test that the organization option is forwarded to the underlying client unchanged.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + organization="org_abc123", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.return_value = TokenExchangeResponse( + access_token="token", token_type="Bearer", expires_in=3600 + ) + + await auth_client.custom_token_exchange(options) + + mock_exchange.assert_called_once_with(options, store_options=None) + + @pytest.mark.asyncio + async def test_custom_token_exchange_organization_validation_error_propagates(self, auth_client): + """Test that OrganizationTokenValidationError from the underlying client is not swallowed or wrapped.""" + options = CustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:legacy-session-token", + organization="org_abc123", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.side_effect = OrganizationTokenValidationError( + "Organization Id (org_id) claim value mismatch in the ID token" + ) + + with pytest.raises(OrganizationTokenValidationError): + await auth_client.custom_token_exchange(options) + @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" @@ -764,3 +803,27 @@ async def test_login_with_custom_token_exchange_missing_response_raises_value_er with pytest.raises(ValueError): await auth_client.login_with_custom_token_exchange(options, store_options={}) + + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_organization_validation_error_propagates( + self, auth_client, mock_request, mock_response + ): + """Test that OrganizationTokenValidationError from the underlying client is not swallowed or wrapped.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + organization="org_abc123", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.side_effect = OrganizationTokenValidationError( + "Organization Id (org_id) claim value mismatch in the ID token" + ) + + with pytest.raises(OrganizationTokenValidationError): + await auth_client.login_with_custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) From 5f7f550c0e2a8fb34354594370ee22079aaba0da Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Fri, 28 Aug 2026 23:26:48 +0530 Subject: [PATCH 02/18] fix: scope CTE org support to parameter forwarding --- examples/CustomTokenExchange.md | 18 +++------ src/auth0_fastapi/auth/auth_client.py | 6 --- src/auth0_fastapi/errors/__init__.py | 3 -- src/auth0_fastapi/test/test_auth_client.py | 47 +--------------------- 4 files changed, 6 insertions(+), 68 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 4ac852f..f69e681 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -105,10 +105,8 @@ async def get_profile(session: dict = Depends(auth_client.require_session)): ## 3. Scoping the Exchange to an Organization Pass `organization` (an org ID like `org_abc123`, or an org name) to scope the exchange to a specific -Auth0 Organization. The returned token's `org_id`/`org_name` claim is verified against the value you -passed — if the token comes back scoped to a different organization, or carries no ID token to check -at all, the exchange raises `OrganizationTokenValidationError` instead of returning a token silently -scoped to the wrong tenant: +Auth0 Organization. The SDK forwards it to Auth0, which resolves the organization and rejects the +exchange with a `CustomTokenExchangeError` if the subject is not a member of that organization: ```python result = await auth_client.login_with_custom_token_exchange( @@ -123,7 +121,7 @@ result = await auth_client.login_with_custom_token_exchange( ## 4. Error Handling -Register the SDK's exception handler once, and `CustomTokenExchangeError` / `OrganizationTokenValidationError` will be mapped to HTTP JSON responses automatically: +Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP JSON response automatically: ```python from auth0_fastapi.errors import register_exception_handlers @@ -135,11 +133,7 @@ Or handle it inline if a route needs custom behavior: ```python from fastapi import HTTPException -from auth0_fastapi.errors import ( - CustomTokenExchangeError, - CustomTokenExchangeErrorCode, - OrganizationTokenValidationError, -) +from auth0_fastapi.errors import CustomTokenExchangeError, CustomTokenExchangeErrorCode @router.post("/api/exchange") async def exchange_token(request: Request, response: Response): @@ -152,8 +146,6 @@ async def exchange_token(request: Request, response: Response): ), store_options={"request": request, "response": response}, ) - except OrganizationTokenValidationError: - raise HTTPException(status_code=401, detail="Token was not issued for the requested organization") except CustomTokenExchangeError as e: if e.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT: raise HTTPException(status_code=400, detail="Invalid subject token") @@ -168,7 +160,7 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 `INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -`OrganizationTokenValidationError` is raised after the exchange completes, only when `organization` was passed: if the returned token has no ID token to verify, or its `org_id`/`org_name` claim doesn't match the requested organization. +When `organization` is passed and the subject is not a member of that organization, Auth0 rejects the exchange and the SDK surfaces it as a `CustomTokenExchangeError`. ## 5. Token Type URIs diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index f07f339..f9468a3 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -247,9 +247,6 @@ async def custom_token_exchange( Raises: CustomTokenExchangeError: If the exchange fails or the subject token parameters are invalid (see CustomTokenExchangeErrorCode). - OrganizationTokenValidationError: If organization was requested - and the returned token does not carry a matching org_id/org_name - claim, or carries no ID token to verify it against. """ return await self.client.custom_token_exchange(options, store_options=store_options) @@ -277,9 +274,6 @@ async def login_with_custom_token_exchange( Raises: CustomTokenExchangeError: If the exchange fails or the subject token parameters are invalid (see CustomTokenExchangeErrorCode). - OrganizationTokenValidationError: If organization was requested - and the returned token does not carry a matching org_id/org_name - claim, or carries no ID token to verify it against. ValueError: If store_options is missing the response needed to write the session cookie. """ diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index be3cae9..0e82368 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -10,7 +10,6 @@ IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, - OrganizationTokenValidationError, ) from fastapi import Request from fastapi.responses import JSONResponse @@ -40,8 +39,6 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): status_code = 422 # Unprocessable Entity elif isinstance(exc, IssuerValidationError): status_code = 401 # Unauthorized - token issuer mismatch - elif isinstance(exc, OrganizationTokenValidationError): - status_code = 401 # Unauthorized - organization claim mismatch elif isinstance(exc, ApiError): status_code = 502 # Bad Gateway, indicates an upstream error elif isinstance(exc, AccessTokenError): diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index febd1ea..1de882a 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -10,11 +10,7 @@ LoginWithCustomTokenExchangeResult, TokenExchangeResponse, ) -from auth0_server_python.error import ( - CustomTokenExchangeError, - CustomTokenExchangeErrorCode, - OrganizationTokenValidationError, -) +from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -716,23 +712,6 @@ async def test_custom_token_exchange_organization_forwarded(self, auth_client): mock_exchange.assert_called_once_with(options, store_options=None) - @pytest.mark.asyncio - async def test_custom_token_exchange_organization_validation_error_propagates(self, auth_client): - """Test that OrganizationTokenValidationError from the underlying client is not swallowed or wrapped.""" - options = CustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:legacy-session-token", - organization="org_abc123", - ) - - with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: - mock_exchange.side_effect = OrganizationTokenValidationError( - "Organization Id (org_id) claim value mismatch in the ID token" - ) - - with pytest.raises(OrganizationTokenValidationError): - await auth_client.custom_token_exchange(options) - @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" @@ -803,27 +782,3 @@ async def test_login_with_custom_token_exchange_missing_response_raises_value_er with pytest.raises(ValueError): await auth_client.login_with_custom_token_exchange(options, store_options={}) - - @pytest.mark.asyncio - async def test_login_with_custom_token_exchange_organization_validation_error_propagates( - self, auth_client, mock_request, mock_response - ): - """Test that OrganizationTokenValidationError from the underlying client is not swallowed or wrapped.""" - options = LoginWithCustomTokenExchangeOptions( - subject_token="external-token", - subject_token_type="urn:acme:corporate-idp-token", - organization="org_abc123", - ) - - with patch.object( - auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock - ) as mock_login_exchange: - mock_login_exchange.side_effect = OrganizationTokenValidationError( - "Organization Id (org_id) claim value mismatch in the ID token" - ) - - with pytest.raises(OrganizationTokenValidationError): - await auth_client.login_with_custom_token_exchange( - options, - store_options={"request": mock_request, "response": mock_response}, - ) From a5c298b0dfacf232d472607acbed952ffcbfdd40 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 31 Aug 2026 00:40:20 +0530 Subject: [PATCH 03/18] refactor: tighten comments, docstrings, and example prose --- examples/CustomTokenExchange.md | 15 +++--- src/auth0_fastapi/auth/auth_client.py | 53 ++++++---------------- src/auth0_fastapi/errors/__init__.py | 32 +++++-------- src/auth0_fastapi/test/test_auth_client.py | 7 --- 4 files changed, 33 insertions(+), 74 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index f69e681..c8f24db 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -8,7 +8,7 @@ Custom Token Exchange lets your FastAPI backend exchange a token from an externa | Method | Session effect | Use case | |---|---|---| -| `custom_token_exchange()` | None — the current user session (if any) is untouched | Calling a downstream API with a different audience/scope | +| `custom_token_exchange()` | None. The current session is untouched. | Calling a downstream API with a different audience/scope | | `login_with_custom_token_exchange()` | Establishes a full Auth0 session, same as completing `/auth/callback` | Logging a user into your app using a token issued by an external system | Both methods are programmatic — there is no dedicated route mounted by the SDK. You call them from your own route handler. @@ -50,7 +50,6 @@ async def exchange_token(request: Request, response: Response): ```python from auth0_server_python.auth_types import CustomTokenExchangeOptions -# No FastAPI Request/Response involved — e.g. a background worker result = await auth_client.custom_token_exchange( CustomTokenExchangeOptions( subject_token="service-token", @@ -82,13 +81,11 @@ async def token_exchange_login(request: Request, response: Response): store_options={"request": request, "response": response}, ) - # The session cookie is now set on `response`. Subsequent requests - # can use `Depends(auth_client.require_session)` as usual. user = result.state_data["user"] return {"user": user} ``` -Passing `response` in `store_options` is required here — it is how the session store writes the `Set-Cookie` header. Omitting it will raise a `ValueError` from the underlying state store. +Passing `response` in `store_options` is required. The session store uses it to write the `Set-Cookie` header. Omitting it raises a `ValueError` from the underlying state store. Once this route completes, protected routes work immediately: @@ -105,8 +102,8 @@ async def get_profile(session: dict = Depends(auth_client.require_session)): ## 3. Scoping the Exchange to an Organization Pass `organization` (an org ID like `org_abc123`, or an org name) to scope the exchange to a specific -Auth0 Organization. The SDK forwards it to Auth0, which resolves the organization and rejects the -exchange with a `CustomTokenExchangeError` if the subject is not a member of that organization: +Auth0 Organization. The SDK forwards it to Auth0. Auth0 rejects the exchange with `CustomTokenExchangeError` +if the subject is not a member of that organization. ```python result = await auth_client.login_with_custom_token_exchange( @@ -160,11 +157,11 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 `INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -When `organization` is passed and the subject is not a member of that organization, Auth0 rejects the exchange and the SDK surfaces it as a `CustomTokenExchangeError`. +When `organization` is set and the subject is not a member, Auth0 rejects the exchange as `CustomTokenExchangeError`. ## 5. Token Type URIs -`subject_token_type` accepts any URI — a standard RFC 8693 URN (e.g. `urn:ietf:params:oauth:token-type:jwt`) or your own namespace (e.g. `urn:acme:legacy-session-token`). +`subject_token_type` accepts any URI, either a standard RFC 8693 URN (e.g. `urn:ietf:params:oauth:token-type:jwt`) or your own namespace (e.g. `urn:acme:legacy-session-token`). See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md) for the full set of standard token-type URNs, and the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for which namespaces are reserved and cannot be used as a *custom* `subject_token_type` when configuring a token-exchange profile in the Auth0 Dashboard. diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index f9468a3..b1cd2bf 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -21,12 +21,7 @@ class AuthClient: - """ - FastAPI SDK client that wraps auth0-server-python functionality. - It configures the underlying client with the proper state and transaction stores, - and exposes helper methods for starting login, completing the login callback, - logging out, and handling backchannel logout. - """ + """FastAPI wrapper for auth0-server-python authentication flows.""" def __init__( self, @@ -35,20 +30,16 @@ def __init__( transaction_store=None, ): self.config = config - # Build the redirect URI based on the provided app_base_url redirect_uri = f"{str(config.app_base_url).rstrip('/')}/auth/callback" - # Use provided state_store or default to cookie implementation if state_store is None: state_store = StatelessStateStore( config.secret, cookie_name="_a0_session", expiration=config.session_expiration) - # Use provided transaction_store or default to an cookie implementation if transaction_store is None: transaction_store = CookieTransactionStore( config.secret, cookie_name="_a0_tx") - # When domain is callable (MCD), don't hardcode redirect_uri in authorization_params - # It will be set dynamically per-request based on the incoming host + # MCD resolves the domain per-request, so redirect_uri cannot be set here auth_params = { "audience": config.audience, **(config.authorization_params or {}), @@ -57,10 +48,10 @@ def __init__( auth_params["redirect_uri"] = redirect_uri self.client = ServerClient( - domain=config.domain, # Can be str or callable + domain=config.domain, client_id=config.client_id, client_secret=config.client_secret, - redirect_uri=redirect_uri, # Default fallback + redirect_uri=redirect_uri, secret=config.secret, transaction_store=transaction_store, state_store=state_store, @@ -230,23 +221,17 @@ async def custom_token_exchange( store_options: dict = None, ) -> TokenExchangeResponse: """ - Performs an RFC 8693 token exchange for the given subject token. - Does not create or modify the current session. + Exchanges a subject token for Auth0 tokens via RFC 8693 without creating a session. Args: - options: Subject token details and exchange parameters - (subject_token, subject_token_type, audience, scope, - organization, authorization_params). - store_options: Optional options passed to the Transaction and State - Store. Only required when using Multiple Custom Domains, where - the domain resolver needs the incoming request. + options: Exchange configuration. + store_options: Store options. Required for Multiple Custom Domain domain resolution. Returns: - The raw TokenExchangeResponse (access_token, expires_in). + TokenExchangeResponse. Raises: - CustomTokenExchangeError: If the exchange fails or the subject - token parameters are invalid (see CustomTokenExchangeErrorCode). + CustomTokenExchangeError: If the exchange fails. """ return await self.client.custom_token_exchange(options, store_options=store_options) @@ -256,25 +241,17 @@ async def login_with_custom_token_exchange( store_options: dict = None, ) -> LoginWithCustomTokenExchangeResult: """ - Performs an RFC 8693 token exchange for the given subject token and - establishes a session for the resulting user. + Exchanges a subject token for Auth0 tokens via RFC 8693 and establishes a session. Args: - options: Subject token details and exchange parameters - (subject_token, subject_token_type, audience, scope, - organization, authorization_params). - store_options: Options passed to the Transaction and State Store. - Must include {"request": request, "response": response} so the - session cookie can be written on response. + options: Exchange configuration. + store_options: Must include request and response to write the session cookie. Returns: - The LoginWithCustomTokenExchangeResult containing the session state - (including the resulting user). + LoginWithCustomTokenExchangeResult. Raises: - CustomTokenExchangeError: If the exchange fails or the subject - token parameters are invalid (see CustomTokenExchangeErrorCode). - ValueError: If store_options is missing the response needed to - write the session cookie. + CustomTokenExchangeError: If the exchange fails. + ValueError: If store_options is missing the response. """ return await self.client.login_with_custom_token_exchange(options, store_options=store_options) diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index 0e82368..50e9246 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -1,4 +1,3 @@ -#Imported from auth0-server-python from auth0_server_python.error import ( AccessTokenError, AccessTokenForConnectionError, @@ -16,37 +15,32 @@ class ConfigurationError(Auth0Error): - """ - Error raised when an invalid configuration is used. - """ + """Invalid SDK configuration.""" + code = "configuration_error" def __init__(self, message=None): - super().__init__(message or "An invalid configuration was provided.") + super().__init__(message or "Invalid SDK configuration.") self.name = "ConfigurationError" def auth0_exception_handler(request: Request, exc: Auth0Error): - """ - Exception handler for Auth0 SDK errors. - Maps different Auth0 errors to appropriate HTTP status codes. - """ - # Set a default status code + """Maps Auth0 SDK errors to HTTP status codes.""" status_code = 400 if isinstance(exc, MissingTransactionError): - status_code = 404 # Not Found + status_code = 404 elif isinstance(exc, MissingRequiredArgumentError): - status_code = 422 # Unprocessable Entity + status_code = 422 elif isinstance(exc, IssuerValidationError): - status_code = 401 # Unauthorized - token issuer mismatch + status_code = 401 elif isinstance(exc, ApiError): - status_code = 502 # Bad Gateway, indicates an upstream error + status_code = 502 elif isinstance(exc, AccessTokenError): - status_code = 401 # Unauthorized + status_code = 401 elif isinstance(exc, BackchannelLogoutError): - status_code = 400 # Bad Request + status_code = 400 elif isinstance(exc, AccessTokenForConnectionError): - status_code = 400 # Bad Request + status_code = 400 return JSONResponse( status_code=status_code, @@ -57,7 +51,5 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): ) def register_exception_handlers(app): - """ - Register all Auth0-related exception handlers with the FastAPI app. - """ + """Registers the Auth0 exception handler with the FastAPI app.""" app.add_exception_handler(Auth0Error, auth0_exception_handler) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 1de882a..cd34856 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -634,7 +634,6 @@ class TestCustomTokenExchange: @pytest.mark.asyncio async def test_custom_token_exchange_success(self, auth_client, mock_request, mock_response): - """Test that custom_token_exchange delegates to the underlying client and returns its result.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -661,7 +660,6 @@ async def test_custom_token_exchange_success(self, auth_client, mock_request, mo @pytest.mark.asyncio async def test_custom_token_exchange_does_not_touch_session(self, auth_client): - """Test that custom_token_exchange never calls get_session or writes to the state store.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -679,7 +677,6 @@ async def test_custom_token_exchange_does_not_touch_session(self, auth_client): @pytest.mark.asyncio async def test_custom_token_exchange_error_propagates(self, auth_client): - """Test that CustomTokenExchangeError from the underlying client is not swallowed or wrapped.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -696,7 +693,6 @@ async def test_custom_token_exchange_error_propagates(self, auth_client): @pytest.mark.asyncio async def test_custom_token_exchange_organization_forwarded(self, auth_client): - """Test that the organization option is forwarded to the underlying client unchanged.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -714,7 +710,6 @@ async def test_custom_token_exchange_organization_forwarded(self, auth_client): @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): - """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" options = LoginWithCustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:corporate-idp-token", @@ -743,7 +738,6 @@ async def test_login_with_custom_token_exchange_success(self, auth_client, mock_ async def test_login_with_custom_token_exchange_passes_store_options_for_session_write( self, auth_client, mock_request, mock_response ): - """Test that request and response are forwarded so the state store can write the session cookie.""" options = LoginWithCustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:corporate-idp-token", @@ -767,7 +761,6 @@ async def test_login_with_custom_token_exchange_passes_store_options_for_session @pytest.mark.asyncio async def test_login_with_custom_token_exchange_missing_response_raises_value_error(self, auth_client): - """Test that omitting response in store_options surfaces the ValueError from the state store.""" options = LoginWithCustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:corporate-idp-token", From 378e1a1d1681b12e0190bcb97f7a15e1fdb273c8 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 7 Sep 2026 14:09:55 +0530 Subject: [PATCH 04/18] feat: add request_session_transfer_token and build_session_transfer_redirect wrappers Exposes STT impersonation methods on AuthClient with flat-param signatures matching the existing SDK wrapper convention. Bumps auth0-server-python min version to b14 (first release with SessionTransferTokenResult). Tests cover delegation, actor forwarding, org forwarding, ACTOR_UNAVAILABLE propagation, no-session-write invariant, and invalid-URL error path. Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 2 +- src/auth0_fastapi/auth/auth_client.py | 75 ++++++++ src/auth0_fastapi/test/test_auth_client.py | 205 ++++++++++++++++++++- 3 files changed, 280 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1f391fc..3c37a3d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ packages = [ [tool.poetry.dependencies] python = ">=3.9" -auth0-server-python = ">=1.0.0b13" +auth0-server-python = ">=1.0.0b14" fastapi = ">=0.115.11" pydantic = "^2.12.5" diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index b1cd2bf..c16a5f3 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -10,6 +10,7 @@ LoginWithCustomTokenExchangeOptions, LoginWithCustomTokenExchangeResult, LogoutOptions, + SessionTransferTokenResult, StartInteractiveLoginOptions, TokenExchangeResponse, ) @@ -255,3 +256,77 @@ async def login_with_custom_token_exchange( ValueError: If store_options is missing the response. """ return await self.client.login_with_custom_token_exchange(options, store_options=store_options) + + async def request_session_transfer_token( + self, + subject_token: str, + subject_token_type: str, + actor_token: Optional[str] = None, + actor_token_type: Optional[str] = None, + scope: Optional[str] = None, + organization: Optional[str] = None, + store_options: dict = None, + ) -> SessionTransferTokenResult: + """ + Requests a Session Transfer Token (STT) for impersonation via session transfer. + + The returned STT is opaque and single-use — pass it directly to + build_session_transfer_redirect and do not decode or store it. + + Args: + subject_token: Proof of which customer to impersonate (validated by your CTE Action). + subject_token_type: Token type URI routing to your CTE Profile. + actor_token: The acting party's token. Defaults to the agent session's ID token. + actor_token_type: Type URI of the actor token. Defaults to the ID token URN. + scope: Space-delimited scopes (optional). + organization: Organization identifier (optional). + store_options: Must include request and response to read the agent session. + + Returns: + SessionTransferTokenResult containing the STT and its metadata. + + Raises: + CustomTokenExchangeError: If no actor can be resolved or the exchange fails. + InvalidArgumentError: If organization is provided but blank. + """ + return await self.client.request_session_transfer_token( + subject_token=subject_token, + subject_token_type=subject_token_type, + actor_token=actor_token, + actor_token_type=actor_token_type, + scope=scope, + organization=organization, + store_options=store_options, + ) + + def build_session_transfer_redirect( + self, + target_login_url: str, + result: SessionTransferTokenResult, + organization: Optional[str] = None, + ) -> str: + """ + Builds the redirect URL that hands the STT to the target app's login URL. + + target_login_url must be a trusted, app-controlled absolute https URL + (http is allowed only for localhost/loopback) — the STT is a single-use + credential and must not leak to an untrusted host. + + Args: + target_login_url: The target app's login URL (absolute, https). + result: The SessionTransferTokenResult from request_session_transfer_token. + organization: Organization identifier to forward (optional). + + Returns: + URL string with session_transfer_token (and organization) as query parameters. + + Raises: + MissingRequiredArgumentError: If target_login_url is missing or blank. + InvalidArgumentError: If target_login_url is not an absolute https URL, + or organization is blank. + """ + return self.client.build_session_transfer_redirect( + target_login_url=target_login_url, + result=result, + organization=organization, + ) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index cd34856..c0d9d82 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -8,9 +8,10 @@ CustomTokenExchangeOptions, LoginWithCustomTokenExchangeOptions, LoginWithCustomTokenExchangeResult, + SessionTransferTokenResult, TokenExchangeResponse, ) -from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode +from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode, InvalidArgumentError from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -775,3 +776,205 @@ async def test_login_with_custom_token_exchange_missing_response_raises_value_er with pytest.raises(ValueError): await auth_client.login_with_custom_token_exchange(options, store_options={}) + + +class TestSessionTransferToken: + """Test Session Transfer Token (STT) impersonation flow — RFC 8693 + Auth0 STT extension.""" + + @pytest.mark.asyncio + async def test_request_session_transfer_token_delegates_to_core( + self, auth_client, mock_request, mock_response + ): + """Test that request_session_transfer_token delegates to the underlying ServerClient.""" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + token_type="N_A", + ) + + with patch.object( + auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock + ) as mock_request_stt: + mock_request_stt.return_value = mock_result + + result = await auth_client.request_session_transfer_token( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_request_stt.assert_called_once_with( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + actor_token=None, + actor_token_type=None, + scope=None, + organization=None, + store_options={"request": mock_request, "response": mock_response}, + ) + + @pytest.mark.asyncio + async def test_request_session_transfer_token_with_explicit_actor(self, auth_client): + """Test that an explicit actor_token is forwarded to the core without modification.""" + actor_token = "agent-id-token" + actor_token_type = "urn:ietf:params:oauth:token-type:id_token" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + ) + + with patch.object( + auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock + ) as mock_request_stt: + mock_request_stt.return_value = mock_result + + await auth_client.request_session_transfer_token( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + actor_token=actor_token, + actor_token_type=actor_token_type, + ) + + call_kwargs = mock_request_stt.call_args.kwargs + assert call_kwargs["actor_token"] == actor_token + assert call_kwargs["actor_token_type"] == actor_token_type + + @pytest.mark.asyncio + async def test_request_session_transfer_token_with_organization(self, auth_client): + """Test that organization is forwarded to the core for org-scoped STT minting.""" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + ) + + with patch.object( + auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock + ) as mock_request_stt: + mock_request_stt.return_value = mock_result + + await auth_client.request_session_transfer_token( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + organization="org_abc123", + ) + + assert mock_request_stt.call_args.kwargs["organization"] == "org_abc123" + + @pytest.mark.asyncio + async def test_request_session_transfer_token_actor_unavailable_propagates(self, auth_client): + """Test that ACTOR_UNAVAILABLE propagates when no agent session exists.""" + with patch.object( + auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock + ) as mock_request_stt: + mock_request_stt.side_effect = CustomTokenExchangeError( + CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE, + "No usable actor token: pass actor_token or ensure the agent has a valid session.", + ) + + with pytest.raises(CustomTokenExchangeError) as exc_info: + await auth_client.request_session_transfer_token( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + ) + + assert exc_info.value.code == CustomTokenExchangeErrorCode.ACTOR_UNAVAILABLE + + @pytest.mark.asyncio + async def test_request_session_transfer_token_does_not_write_session(self, auth_client): + """Test that the STT is returned as-is and never written to the session store.""" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + ) + + with patch.object( + auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock + ) as mock_request_stt, \ + patch.object(auth_client.client, 'get_session', new_callable=AsyncMock) as mock_get_session: + mock_request_stt.return_value = mock_result + + await auth_client.request_session_transfer_token( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + ) + + mock_get_session.assert_not_called() + + def test_build_session_transfer_redirect_delegates_to_core(self, auth_client): + """Test that build_session_transfer_redirect delegates to the underlying ServerClient.""" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + ) + expected_url = "https://app.example.com/auth/login?session_transfer_token=opaque-stt-value" + + with patch.object( + auth_client.client, 'build_session_transfer_redirect' + ) as mock_build: + mock_build.return_value = expected_url + + result = auth_client.build_session_transfer_redirect( + "https://app.example.com/auth/login", + mock_result, + ) + + assert result == expected_url + mock_build.assert_called_once_with( + target_login_url="https://app.example.com/auth/login", + result=mock_result, + organization=None, + ) + + def test_build_session_transfer_redirect_with_organization(self, auth_client): + """Test that organization is appended to the redirect URL when provided.""" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + ) + expected_url = ( + "https://app.example.com/auth/login" + "?session_transfer_token=opaque-stt-value&organization=org_abc123" + ) + + with patch.object( + auth_client.client, 'build_session_transfer_redirect' + ) as mock_build: + mock_build.return_value = expected_url + + result = auth_client.build_session_transfer_redirect( + "https://app.example.com/auth/login", + mock_result, + organization="org_abc123", + ) + + assert result == expected_url + assert mock_build.call_args.kwargs["organization"] == "org_abc123" + + def test_build_session_transfer_redirect_invalid_url_propagates(self, auth_client): + """Test that a non-https target URL raises InvalidArgumentError from the core.""" + mock_result = SessionTransferTokenResult( + session_transfer_token="opaque-stt-value", + issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", + expires_in=60, + ) + + with patch.object( + auth_client.client, 'build_session_transfer_redirect' + ) as mock_build: + mock_build.side_effect = InvalidArgumentError( + "target_login_url", + "target_login_url must be an absolute https URL", + ) + + with pytest.raises(InvalidArgumentError): + auth_client.build_session_transfer_redirect( + "http://untrusted.example.com/login", + mock_result, + ) From eb40fadd7d7fa17bf7725ffa689723b15931d76b Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 7 Sep 2026 17:41:16 +0530 Subject: [PATCH 05/18] refactor: strip narrating comments, em dashes, and cross-impl references --- README.md | 2 +- examples/CustomTokenExchange.md | 6 +- examples/MultipleCustomDomains.md | 14 ++-- src/auth0_fastapi/auth/auth_client.py | 73 ++++--------------- src/auth0_fastapi/server/routes.py | 63 ++-------------- .../stores/cookie_transaction_store.py | 11 +-- .../stores/stateful_state_store.py | 18 ++--- .../stores/stateless_state_store.py | 14 +--- src/auth0_fastapi/test/test_auth_client.py | 2 +- src/auth0_fastapi/util/__init__.py | 4 - 10 files changed, 41 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 4d53017..0b4fca4 100644 --- a/README.md +++ b/README.md @@ -311,7 +311,7 @@ For detailed behavior and how to read the value, see [examples/Sessions.md](./ex ### Custom Token Exchange -Exchange a token from an external identity provider or legacy system for Auth0 tokens using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) Token Exchange, without a browser redirect — with or without establishing a session. See [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md) for setup and code samples. +Exchange a token from an external identity provider or legacy system for Auth0 tokens using [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693) Token Exchange, without a browser redirect - with or without establishing a session. See [examples/CustomTokenExchange.md](./examples/CustomTokenExchange.md) for setup and code samples. ## Feedback diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index c8f24db..26a65f0 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -11,7 +11,7 @@ Custom Token Exchange lets your FastAPI backend exchange a token from an externa | `custom_token_exchange()` | None. The current session is untouched. | Calling a downstream API with a different audience/scope | | `login_with_custom_token_exchange()` | Establishes a full Auth0 session, same as completing `/auth/callback` | Logging a user into your app using a token issued by an external system | -Both methods are programmatic — there is no dedicated route mounted by the SDK. You call them from your own route handler. +Both methods are programmatic - there is no dedicated route mounted by the SDK. You call them from your own route handler. ## 1. Basic Token Exchange (no session) @@ -45,7 +45,7 @@ async def exchange_token(request: Request, response: Response): } ``` -`store_options` is optional here — `custom_token_exchange()` does not read or write any cookies. Pass it (as `{"request": request, "response": response}`) only if you've configured [Multiple Custom Domains](../README.md#multiple-custom-domains-mcd) with a domain resolver, since the resolver needs the incoming request to pick a domain. Otherwise it can be omitted entirely, which is useful for service-to-service or background-job scenarios where there is no `Request`/`Response` at all: +`store_options` is optional here - `custom_token_exchange()` does not read or write any cookies. Pass it (as `{"request": request, "response": response}`) only if you've configured [Multiple Custom Domains](../README.md#multiple-custom-domains-mcd) with a domain resolver, since the resolver needs the incoming request to pick a domain. Otherwise it can be omitted entirely, which is useful for service-to-service or background-job scenarios where there is no `Request`/`Response` at all: ```python from auth0_server_python.auth_types import CustomTokenExchangeOptions @@ -169,4 +169,4 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 - [Auth0 Custom Token Exchange Documentation](https://auth0.com/docs/authenticate/custom-token-exchange) - [RFC 8693 - OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693) -- [auth0-server-python: Custom Token Exchange](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md) — the underlying protocol implementation this SDK wraps +- [auth0-server-python: Custom Token Exchange](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md) diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index e490139..3dd8646 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -229,9 +229,9 @@ to work if the resolver returns the same Auth0 domain that was used for those le The SDK uses a three-tier fallback to determine the session's domain: -1. **`session.domain`** — new sessions created after MCD was enabled store this field. -2. **Static domain** — if a static `domain` string was configured, it is used as a fallback. -3. **User's issuer claim** — the hostname is extracted from the `iss` claim in the user's +1. **`session.domain`** - new sessions created after MCD was enabled store this field. +2. **Static domain** - if a static `domain` string was configured, it is used as a fallback. +3. **User's issuer claim** - the hostname is extracted from the `iss` claim in the user's ID token (e.g., `https://login.brand-1.com/` yields `login.brand-1.com`). This means legacy sessions created before MCD support will still work as long as the @@ -272,10 +272,10 @@ The SDK passes request headers to your domain resolver via `DomainResolverContex The SDK uses the resolved domain to fetch OIDC metadata and JWKS. If an attacker can influence the resolved domain, they could point the SDK at an OIDC provider they control. -**Always use a mapping or allowlist — never construct domains from raw header values:** +**Always use a mapping or allowlist - never construct domains from raw header values:** ```python -# Safe: allowlist lookup — unknown hosts fall back to default +# Safe: allowlist lookup - unknown hosts fall back to default DOMAIN_MAP = { "brand-1.yourapp.com": "login.brand-1.com", "brand-2.yourapp.com": "login.brand-2.com", @@ -287,7 +287,7 @@ async def domain_resolver(context: DomainResolverContext) -> str: ``` ```python -# Risky: constructs domain from raw input — attacker can influence resolved domain +# Risky: constructs domain from raw input - attacker can influence resolved domain async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] subdomain = host.split(".")[0] @@ -302,7 +302,7 @@ Without a trusted proxy layer to validate these headers, an attacker can manipul ### Trust Forwarded Headers Only Behind a Proxy -If your application is directly exposed to the internet (not behind a reverse proxy), do not trust `x-forwarded-host` or `x-forwarded-proto` — any client can set these headers. +If your application is directly exposed to the internet (not behind a reverse proxy), do not trust `x-forwarded-host` or `x-forwarded-proto` - any client can set these headers. Only use forwarded headers when your application runs behind a trusted reverse proxy (nginx, AWS ALB, Cloudflare, etc.) that sets these headers and strips any client-provided values. diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index c16a5f3..2b6d4ab 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -1,5 +1,4 @@ -# Imported from auth0-server-python from typing import Optional from auth0_server_python.auth_server.server_client import ServerClient @@ -66,11 +65,7 @@ async def start_login( authorization_params: dict = None, store_options: dict = None, ) -> str: - """ - Initiates the interactive login process. - Optionally, an app_state dictionary can be passed to persist additional state. - Returns the authorization URL to redirect the user. - """ + """Initiates the interactive login flow and returns the authorization URL.""" pushed_authorization_requests = self.config.pushed_authorization_requests options = StartInteractiveLoginOptions( pushed_authorization_requests=pushed_authorization_requests, @@ -84,10 +79,7 @@ async def complete_login( callback_url: str, store_options: dict = None, ) -> dict: - """ - Completes the interactive login process using the callback URL. - Returns a dictionary with the session state data. - """ + """Completes the login callback and returns the session state.""" return await self.client.complete_interactive_login(callback_url, store_options=store_options) async def start_connect_account( @@ -98,11 +90,7 @@ async def start_connect_account( authorization_params: dict = None, store_options: dict = None, ) -> str: - """ - Initiates the connected account process. - Optionally, an app_state dictionary can be passed to persist additional state. - Returns the connect URL to redirect the user. - """ + """Initiates the connected account flow and returns the redirect URL.""" options = ConnectAccountOptions( connection=connection, scopes=scopes, @@ -116,10 +104,7 @@ async def complete_connect_account( url: str, store_options: dict = None, ) -> CompleteConnectAccountResponse: - """ - Completes the connect account process using the callback URL. - Returns the completed connect account response. - """ + """Completes the connect account callback and returns the response.""" return await self.client.complete_connect_account(url, store_options=store_options) async def logout( @@ -127,10 +112,7 @@ async def logout( return_to: str = None, store_options: dict = None, ) -> str: - """ - Initiates logout by clearing the session and generating a logout URL. - Optionally accepts a return_to URL for redirection after logout. - """ + """Initiates logout and returns the Auth0 logout URL.""" options = LogoutOptions(return_to=return_to) return await self.client.logout(options, store_options=store_options) @@ -139,9 +121,7 @@ async def handle_backchannel_logout( logout_token: str, store_options: dict = None, ) -> None: - """ - Processes a backchannel logout using the provided logout token. - """ + """Processes a backchannel logout notification.""" return await self.client.handle_backchannel_logout(logout_token, store_options=store_options) async def start_link_user( @@ -149,15 +129,7 @@ async def start_link_user( options: dict, store_options: dict = None, ) -> str: - """ - Initiates the user linking process. - Options should include: - - connection: connection identifier (e.g. 'google-oauth2') - - connectionScope: (optional) the scope for the connection - - authorizationParams: additional parameters for the /authorize call - - appState: any custom state to track (e.g., a returnTo URL) - Returns a URL to redirect the user to for linking. - """ + """Initiates the user linking flow and returns the redirect URL.""" return await self.client.start_link_user(options, store_options=store_options) async def complete_link_user( @@ -165,11 +137,7 @@ async def complete_link_user( url: str, store_options: dict = None, ) -> dict: - """ - Completes the user linking process. - The provided URL should be the callback URL from Auth0. - Returns a dictionary containing the original appState. - """ + """Completes the user linking callback and returns the app state.""" return await self.client.complete_link_user(url, store_options=store_options) async def start_unlink_user( @@ -177,14 +145,7 @@ async def start_unlink_user( options: dict, store_options: dict = None, ) -> str: - """ - Initiates the user unlinking process. - Options should include: - - connection: connection identifier (e.g. 'google-oauth2') - - authorizationParams: additional parameters for the /authorize call - - appState: any custom state to track (e.g., a returnTo URL) - Returns a URL to redirect the user to for unlinking. - """ + """Initiates the user unlinking flow and returns the redirect URL.""" return await self.client.start_unlink_user(options, store_options=store_options) async def complete_unlink_user( @@ -192,11 +153,7 @@ async def complete_unlink_user( url: str, store_options: dict = None, ) -> dict: - """ - Completes the user unlinking process. - The provided URL should be the callback URL from Auth0. - Returns a dictionary containing the original appState. - """ + """Completes the user unlinking callback and returns the app state.""" return await self.client.complete_unlink_user(url, store_options=store_options) async def require_session( @@ -204,11 +161,7 @@ async def require_session( request: Request, response: Response, ) -> dict: - """ - Dependency method to ensure a session exists. - Retrieves the session from the state store using the underlying client. - If no session is found, raises an HTTP 401 error. - """ + """FastAPI dependency that returns the current session or raises HTTP 401.""" store_options = {"request": request, "response": response} session = await self.client.get_session(store_options=store_options) if not session: @@ -270,7 +223,7 @@ async def request_session_transfer_token( """ Requests a Session Transfer Token (STT) for impersonation via session transfer. - The returned STT is opaque and single-use — pass it directly to + The returned STT is opaque and single-use - pass it directly to build_session_transfer_redirect and do not decode or store it. Args: @@ -309,7 +262,7 @@ def build_session_transfer_redirect( Builds the redirect URL that hands the STT to the target app's login URL. target_login_url must be a trusted, app-controlled absolute https URL - (http is allowed only for localhost/loopback) — the STT is a single-use + (http is allowed only for localhost/loopback) - the STT is a single-use credential and must not leak to an untrusted host. Args: diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index 48a2e05..2038ac7 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -12,10 +12,7 @@ def get_auth_client(request: Request) -> AuthClient: - """ - Dependency function to retrieve the AuthClient instance. - Assumes the client is set on the FastAPI application state. - """ + """Dependency to retrieve the AuthClient from app state.""" auth_client = request.app.state.auth_client if not auth_client: raise HTTPException( @@ -28,9 +25,6 @@ def register_auth_routes(router: APIRouter, config: Auth0Config): Conditionally register auth routes based on config.mount_routes and config.mount_connect_routes. """ if config.mount_connect_routes and config.mount_connected_account_routes: - # Connect routes uses the legacy account linking flow for token vault - # Connects Accounts is the preferred mechanism - # Both mount the `/auth/connect` route to initiate the flow raise ConfigurationError( "'mount_connect_routes' and 'mount_connected_account_routes' cannot be used together.") @@ -41,20 +35,12 @@ async def login( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - """ - Endpoint to initiate the login process. - Optionally accepts a 'return_to' query parameter and passes it as part of the app state. - Redirects the user to the Auth0 authorization URL. - - When domain is a callable (MCD), the redirect_uri is built dynamically - from the request host to ensure proper domain handling. - """ + """Initiates the Auth0 login flow and redirects to the authorization URL.""" return_to: Optional[str] = request.query_params.get("returnTo") authorization_params = {k: v for k, v in request.query_params.items() if k not in [ "returnTo"]} - # Build dynamic redirect_uri from request host when domain is callable if callable(auth_client.config.domain): base_url = build_request_base_url(request) authorization_params["redirect_uri"] = f"{base_url}/auth/callback" @@ -73,11 +59,7 @@ async def callback( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - """ - Endpoint to handle the callback after Auth0 authentication. - Processes the callback URL and completes the login or connected account flow. - Redirects the user to a post-login URL based on appState or a default. - """ + """Handles the Auth0 callback and redirects to the post-login destination.""" full_callback_url = str(request.url) try: @@ -90,20 +72,15 @@ async def callback( session_data = await auth_client.complete_login( full_callback_url, store_options={"request": request, "response": response}) - # Extract the returnTo URL from the appState if available. app_state = session_data.get("app_state", {}) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - - # Extract the returnTo URL from the appState if available. return_to = app_state.get("returnTo") - # Build dynamic default_redirect from request host if domain is callable if callable(auth_client.config.domain): default_redirect = build_request_base_url(request) else: - # Assuming config is stored on app.state default_redirect = auth_client.config.app_base_url safe_redirect = to_safe_redirect(return_to, default_redirect) if return_to else str(default_redirect) @@ -115,16 +92,9 @@ async def logout( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - """ - Endpoint to handle logout. - Clears the session cookie (if applicable) and generates a logout URL, - then redirects the user to Auth0's logout endpoint. - - For MCD, builds dynamic returnTo URL based on incoming request host. - """ + """Clears the session and redirects to the Auth0 logout endpoint.""" return_to: Optional[str] = request.query_params.get("returnTo") try: - # Build dynamic default_redirect from request host if domain is callable if callable(auth_client.config.domain): default_redirect = build_request_base_url(request) else: @@ -132,7 +102,7 @@ async def logout( logout_url = await auth_client.logout( return_to=return_to or default_redirect, - store_options={"request": request, "response": response}, # Pass request for MCD + store_options={"request": request, "response": response}, ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -144,11 +114,7 @@ async def backchannel_logout( request: Request, auth_client: AuthClient = Depends(get_auth_client), ): - """ - Endpoint to process backchannel logout notifications. - Expects a JSON body with a 'logout_token'. - Returns 204 No Content on success. - """ + """Processes a backchannel logout notification and returns 204 on success.""" body = await request.json() logout_token = body.get("logout_token") if not logout_token: @@ -174,10 +140,7 @@ async def connect_account( return_to: str = Query(default=None, alias="returnTo"), auth_client: AuthClient = Depends(get_auth_client), ): - """ - Endpoint to initiate the connect account flow for linking a third-party account to the user's profile. - Redirects the user to the Auth0 connect account URL. - """ + """Initiates the connected account flow and redirects to the Auth0 connect URL.""" authorization_params = { k: v for k, v in request.query_params.items() if k not in ["connection", "returnTo", "scopes"]} @@ -202,7 +165,6 @@ async def connect( auth_client: AuthClient = Depends(get_auth_client), ): - # Extract query parameters (connection, connectionScope, returnTo) connection = connection or request.query_params.get("connection") connection_scope = connectionScope or request.query_params.get( "connectionScope") @@ -218,13 +180,10 @@ async def connect( sanitized_return_to = to_safe_redirect( dangerous_return_to or "/", auth_client.config.app_base_url) - # Create the callback URL for linking callback_path = "/auth/connect/callback" redirect_uri = create_route_url( callback_path, auth_client.config.app_base_url) - # Call the startLinkUser method on our AuthClient. This method should accept parameters similar to: - # connection, connectionScope, authorizationParams (with redirect_uri), and app_state. link_user_url = await auth_client.start_link_user({ "connection": connection, "connectionScope": connection_scope, @@ -244,7 +203,6 @@ async def connect_callback( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - # Use the full URL from the callback callback_url = str(request.url) try: result = await auth_client.complete_link_user( @@ -254,7 +212,6 @@ async def connect_callback( except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - # Retrieve the returnTo parameter from app_state if available return_to = result.get("app_state", {}).get("returnTo") app_base_url = auth_client.config.app_base_url @@ -271,7 +228,6 @@ async def unconnect( auth_client: AuthClient = Depends(get_auth_client), ): - # Extract query parameters (connection, connectionScope, returnTo) connection = connection or request.query_params.get("connection") dangerous_return_to = returnTo or request.query_params.get( "returnTo") @@ -285,13 +241,10 @@ async def unconnect( sanitized_return_to = to_safe_redirect( dangerous_return_to or "/", auth_client.config.app_base_url) - # Create the callback URL for linking callback_path = "/auth/unconnect/callback" redirect_uri = create_route_url( callback_path, auth_client.config.app_base_url) - # Call the startLinkUser method on our AuthClient. This method should accept parameters similar to: - # connection, connectionScope, authorizationParams (with redirect_uri), and app_state. link_user_url = await auth_client.start_unlink_user({ "connection": connection, "authorization_params": { @@ -310,7 +263,6 @@ async def unconnect_callback( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - # Use the full URL from the callback callback_url = str(request.url) try: result = await auth_client.complete_unlink_user( @@ -320,7 +272,6 @@ async def unconnect_callback( except Exception as e: raise HTTPException(status_code=400, detail=str(e)) - # Retrieve the returnTo parameter from appState if available return_to = result.get("app_state", {}).get("returnTo") app_base_url = auth_client.config.app_base_url diff --git a/src/auth0_fastapi/stores/cookie_transaction_store.py b/src/auth0_fastapi/stores/cookie_transaction_store.py index 56a2cb9..6bff6f5 100644 --- a/src/auth0_fastapi/stores/cookie_transaction_store.py +++ b/src/auth0_fastapi/stores/cookie_transaction_store.py @@ -1,18 +1,12 @@ from typing import Any, Optional from auth0_server_python.auth_types import TransactionData - -#Imported from auth0-server-python from auth0_server_python.store.abstract import TransactionStore from fastapi import Request, Response class CookieTransactionStore(TransactionStore): - """ - Transaction store implementation that uses a cookie to store transaction data. - This store expects the FastAPI Request and Response objects to be provided in the - store_options parameter. - """ + """Cookie-backed transaction store. Requires request and response in store_options.""" def __init__(self, secret: str, cookie_name: str = "_a0_tx"): super().__init__({"secret": secret}) self.cookie_name = cookie_name @@ -32,9 +26,7 @@ async def set( response: Response = options["response"] - # Encrypt the transaction data using the abstract store method: encrypted_value = self.encrypt(identifier, value.model_dump()) - # Set cookie with a short max_age (e.g., 60 seconds for transactions) response.set_cookie( key=self.cookie_name, value=encrypted_value, @@ -61,7 +53,6 @@ async def get( return None try: - # Decrypt the stored value using the abstract store's decrypt method: decrypted_data = self.decrypt(identifier, encrypted_value) return TransactionData.model_validate(decrypted_data) except Exception: diff --git a/src/auth0_fastapi/stores/stateful_state_store.py b/src/auth0_fastapi/stores/stateful_state_store.py index e07f9de..fe76265 100644 --- a/src/auth0_fastapi/stores/stateful_state_store.py +++ b/src/auth0_fastapi/stores/stateful_state_store.py @@ -1,8 +1,6 @@ from typing import Any, Optional from auth0_server_python.auth_types import StateData - -#Imported from auth0-server-python from auth0_server_python.store.abstract import StateStore from fastapi import Response @@ -10,17 +8,14 @@ class StatefulStateStore(StateStore): - """ - A state store implementation that persists session data in a backend store - (for example, Redis or a database). It uses a cookie to keep track of the session ID. - The underlying session store must implement asynchronous get, set, delete, and keys methods. - """ + """Stateful session store backed by a persistent store (Redis, database). Tracks session ID in a cookie.""" def __init__(self, secret: str, store: Any, cookie_name: str = "_a0_session", expiration: int = 259200): """ - :param secret: Secret for encryption (if needed) - :param store: The persistent session store (e.g., a Redis client wrapper) - :param cookie_name: Name of the cookie holding the session identifier - :param expiration: Session expiration time in seconds + Args: + secret: Secret for encryption. + store: Persistent session store (e.g., a Redis client wrapper). + cookie_name: Name of the cookie holding the session identifier. + expiration: Session expiration in seconds. """ self.secret = secret self.store = store @@ -42,7 +37,6 @@ async def set( raise ValueError("Response object is required in store options for stateful storage.") response: Response = options["response"] - # Store the JSON representation. In a real implementation, encrypt if needed. data = state.model_dump_json() await self.store.set(identifier, data, expire=self.expiration) response.set_cookie( diff --git a/src/auth0_fastapi/stores/stateless_state_store.py b/src/auth0_fastapi/stores/stateless_state_store.py index fabfcf7..2c6ade9 100644 --- a/src/auth0_fastapi/stores/stateless_state_store.py +++ b/src/auth0_fastapi/stores/stateless_state_store.py @@ -1,8 +1,6 @@ from typing import Any, Optional, Union from auth0_server_python.auth_types import StateData - -#Imported from auth0-server-python from auth0_server_python.store.abstract import StateStore from fastapi import Response @@ -18,12 +16,11 @@ def __init__(self, secret: str, cookie_name: str = "_a0_session", expiration: in self.expiration = expiration self.max_cookie_size = 4096 - # Default cookie options similar to Fastify's cookie options self.cookie_options = { "httponly": True, "samesite": "lax", "path": "/", - "secure": True, # or set to "auto" if preferred + "secure": True, "max_age": expiration, } @@ -45,12 +42,9 @@ async def set( state_dict = state.dict() else: state_dict = state - # Encrypt the transaction data using the abstract store method: encrypted_data = self.encrypt(identifier, state_dict) - # Calculate chunk size, ensuring space for the key name and additional characters chunk_size = self.max_cookie_size - len(self.cookie_name) - 10 cookies = {} - # Split data into chunks and store in the response cookies for i in range(0, len(encrypted_data), chunk_size): chunk_name = f"{self.cookie_name}_{i // chunk_size}" chunk_value = encrypted_data[i:i + chunk_size] @@ -80,7 +74,6 @@ async def get( request = options["request"] session_parts = [] - # Extract all cookies that match cookie_prefix for key, value in request.cookies.items(): if key.startswith(self.cookie_name): index = int(key.split("_")[-1]) @@ -88,13 +81,12 @@ async def get( if not session_parts: return "" - session_parts.sort() # Sort by index + session_parts.sort() full_encoded_data = "".join(part[1] for part in session_parts) if not full_encoded_data: return None try: - # Decrypt the stored value using the abstract store's decrypt method: decrypted_data = self.decrypt(identifier, full_encoded_data) return decrypted_data except Exception: @@ -113,10 +105,8 @@ async def delete( raise ValueError("Response object is required in store options for stateless storage.") response: Response = options["response"] - # Delete the base cookie if it exists response.delete_cookie(key=self.cookie_name) - # Delete potential cookie chunks (assume a max number of chunks, e.g., 20) for i in range(20): chunk_key = f"{self.cookie_name}_{i}" response.delete_cookie(key=chunk_key) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index c0d9d82..f04b2ca 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -779,7 +779,7 @@ async def test_login_with_custom_token_exchange_missing_response_raises_value_er class TestSessionTransferToken: - """Test Session Transfer Token (STT) impersonation flow — RFC 8693 + Auth0 STT extension.""" + """Test Session Transfer Token (STT) impersonation flow - RFC 8693 + Auth0 STT extension.""" @pytest.mark.asyncio async def test_request_session_transfer_token_delegates_to_core( diff --git a/src/auth0_fastapi/util/__init__.py b/src/auth0_fastapi/util/__init__.py index bf1022b..20cbb76 100644 --- a/src/auth0_fastapi/util/__init__.py +++ b/src/auth0_fastapi/util/__init__.py @@ -51,13 +51,11 @@ def to_safe_redirect(dangerous_redirect: str, safe_base_url: str) -> Optional[st A safe redirect URL string if the origins match, or None otherwise. """ try: - # Ensure safe_base_url is a string. safe_base_url_str = str(safe_base_url) route_url = create_route_url(dangerous_redirect, safe_base_url_str) except Exception: return None - # Build origins from string values safe_origin = urlparse(safe_base_url_str).scheme + \ "://" + urlparse(safe_base_url_str).netloc route_origin = urlparse(route_url).scheme + "://" + \ @@ -87,7 +85,6 @@ def normalize_url(value: str) -> str: parsed = urlparse(value) - # Lowercase scheme and host scheme = (parsed.scheme or "https").lower() if scheme == "http": scheme = "https" @@ -96,7 +93,6 @@ def normalize_url(value: str) -> str: if not host: return "" - # Remove default port port = parsed.port if port and ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)): port = None From 650798e472ba3cd4ac7d43cde63e619e7fa4ef45 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 7 Sep 2026 17:51:48 +0530 Subject: [PATCH 06/18] fix: read backchannel logout token from form body per OIDC spec --- src/auth0_fastapi/server/routes.py | 2 +- src/auth0_fastapi/test/test_routes.py | 16 ++++++---------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index 2038ac7..a6f5e7e 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -115,7 +115,7 @@ async def backchannel_logout( auth_client: AuthClient = Depends(get_auth_client), ): """Processes a backchannel logout notification and returns 204 on success.""" - body = await request.json() + body = await request.form() logout_token = body.get("logout_token") if not logout_token: raise HTTPException( diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index 9abd978..1dfcb6d 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -297,13 +297,11 @@ async def test_backchannel_logout_valid_token(self, mock_auth_client): mock_request = Mock() valid_logout_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0LmF1dGgwLmNvbSJ9.signature" - # Mock request body - mock_request.json = AsyncMock(return_value={"logout_token": valid_logout_token}) + mock_request.form = AsyncMock(return_value={"logout_token": valid_logout_token}) mock_auth_client.handle_backchannel_logout.return_value = None - # Simulate backchannel logout endpoint logic - body = await mock_request.json() + body = await mock_request.form() logout_token = body.get("logout_token") await mock_auth_client.handle_backchannel_logout(logout_token) @@ -314,10 +312,9 @@ async def test_backchannel_logout_valid_token(self, mock_auth_client): async def test_backchannel_logout_missing_token(self, mock_auth_client): """Test backchannel logout with missing logout_token.""" mock_request = Mock() - mock_request.json = AsyncMock(return_value={}) + mock_request.form = AsyncMock(return_value={}) - # Simulate backchannel logout endpoint logic - body = await mock_request.json() + body = await mock_request.form() logout_token = body.get("logout_token") # Should handle missing token gracefully @@ -332,12 +329,11 @@ async def test_backchannel_logout_invalid_token(self, mock_auth_client): """Test backchannel logout with invalid JWT token.""" mock_request = Mock() invalid_token = "invalid.jwt.token" - mock_request.json = AsyncMock(return_value={"logout_token": invalid_token}) + mock_request.form = AsyncMock(return_value={"logout_token": invalid_token}) mock_auth_client.handle_backchannel_logout.side_effect = Exception("Invalid JWT token") - # Simulate backchannel logout endpoint logic - body = await mock_request.json() + body = await mock_request.form() logout_token = body.get("logout_token") with pytest.raises(Exception) as exc_info: From dbdee4e140c3d3762f24115668450a3493589135 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 7 Sep 2026 18:58:09 +0530 Subject: [PATCH 07/18] fix: remove duplicate InvalidArgumentError import in test_auth_client --- src/auth0_fastapi/test/test_auth_client.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 64d72f6..f82328a 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -16,7 +16,6 @@ from auth0_fastapi.auth.auth_client import AuthClient from auth0_fastapi.config import Auth0Config -from auth0_fastapi.errors import InvalidArgumentError @pytest.fixture From 7b8148e7b9042a73b4227cebdb147d93f90d5b8f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Mon, 7 Sep 2026 19:13:36 +0530 Subject: [PATCH 08/18] refactor: restore original method docstrings in AuthClient --- src/auth0_fastapi/auth/auth_client.py | 113 ++++++++++++++++++-------- 1 file changed, 78 insertions(+), 35 deletions(-) diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 625f8e9..9d3df40 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -21,7 +21,12 @@ class AuthClient: - """FastAPI wrapper for auth0-server-python authentication flows.""" + """ + FastAPI SDK client that wraps auth0-server-python functionality. + It configures the underlying client with the proper state and transaction stores, + and exposes helper methods for starting login, completing the login callback, + logging out, and handling backchannel logout. + """ def __init__( self, @@ -65,7 +70,11 @@ async def start_login( authorization_params: dict = None, store_options: dict = None, ) -> str: - """Initiates the interactive login flow and returns the authorization URL.""" + """ + Initiates the interactive login process. + Optionally, an app_state dictionary can be passed to persist additional state. + Returns the authorization URL to redirect the user. + """ pushed_authorization_requests = self.config.pushed_authorization_requests options = StartInteractiveLoginOptions( pushed_authorization_requests=pushed_authorization_requests, @@ -79,7 +88,10 @@ async def complete_login( callback_url: str, store_options: dict = None, ) -> dict: - """Completes the login callback and returns the session state.""" + """ + Completes the interactive login process using the callback URL. + Returns a dictionary with the session state data. + """ return await self.client.complete_interactive_login(callback_url, store_options=store_options) async def start_connect_account( @@ -90,7 +102,11 @@ async def start_connect_account( authorization_params: dict = None, store_options: dict = None, ) -> str: - """Initiates the connected account flow and returns the redirect URL.""" + """ + Initiates the connected account process. + Optionally, an app_state dictionary can be passed to persist additional state. + Returns the connect URL to redirect the user. + """ options = ConnectAccountOptions( connection=connection, scopes=scopes, @@ -104,7 +120,10 @@ async def complete_connect_account( url: str, store_options: dict = None, ) -> CompleteConnectAccountResponse: - """Completes the connect account callback and returns the response.""" + """ + Completes the connect account process using the callback URL. + Returns the completed connect account response. + """ return await self.client.complete_connect_account(url, store_options=store_options) async def logout( @@ -112,7 +131,10 @@ async def logout( return_to: str = None, store_options: dict = None, ) -> str: - """Initiates logout and returns the Auth0 logout URL.""" + """ + Initiates logout by clearing the session and generating a logout URL. + Optionally accepts a return_to URL for redirection after logout. + """ options = LogoutOptions(return_to=return_to) return await self.client.logout(options, store_options=store_options) @@ -121,7 +143,9 @@ async def handle_backchannel_logout( logout_token: str, store_options: dict = None, ) -> None: - """Processes a backchannel logout notification.""" + """ + Processes a backchannel logout using the provided logout token. + """ return await self.client.handle_backchannel_logout(logout_token, store_options=store_options) async def start_link_user( @@ -129,7 +153,15 @@ async def start_link_user( options: dict, store_options: dict = None, ) -> str: - """Initiates the user linking flow and returns the redirect URL.""" + """ + Initiates the user linking process. + Options should include: + - connection: connection identifier (e.g. 'google-oauth2') + - connectionScope: (optional) the scope for the connection + - authorizationParams: additional parameters for the /authorize call + - appState: any custom state to track (e.g., a returnTo URL) + Returns a URL to redirect the user to for linking. + """ return await self.client.start_link_user(options, store_options=store_options) async def complete_link_user( @@ -137,7 +169,11 @@ async def complete_link_user( url: str, store_options: dict = None, ) -> dict: - """Completes the user linking callback and returns the app state.""" + """ + Completes the user linking process. + The provided URL should be the callback URL from Auth0. + Returns a dictionary containing the original appState. + """ return await self.client.complete_link_user(url, store_options=store_options) async def start_unlink_user( @@ -145,7 +181,14 @@ async def start_unlink_user( options: dict, store_options: dict = None, ) -> str: - """Initiates the user unlinking flow and returns the redirect URL.""" + """ + Initiates the user unlinking process. + Options should include: + - connection: connection identifier (e.g. 'google-oauth2') + - authorizationParams: additional parameters for the /authorize call + - appState: any custom state to track (e.g., a returnTo URL) + Returns a URL to redirect the user to for unlinking. + """ return await self.client.start_unlink_user(options, store_options=store_options) async def complete_unlink_user( @@ -153,7 +196,11 @@ async def complete_unlink_user( url: str, store_options: dict = None, ) -> dict: - """Completes the user unlinking callback and returns the app state.""" + """ + Completes the user unlinking process. + The provided URL should be the callback URL from Auth0. + Returns a dictionary containing the original appState. + """ return await self.client.complete_unlink_user(url, store_options=store_options) async def require_session( @@ -161,7 +208,11 @@ async def require_session( request: Request, response: Response, ) -> dict: - """FastAPI dependency that returns the current session or raises HTTP 401.""" + """ + Dependency method to ensure a session exists. + Retrieves the session from the state store using the underlying client. + If no session is found, raises an HTTP 401 error. + """ store_options = {"request": request, "response": response} session = await self.client.get_session(store_options=store_options) if not session: @@ -221,31 +272,25 @@ async def request_session_transfer_token( store_options: dict = None, ) -> SessionTransferTokenResult: """ - Mints a Session Transfer Token (STT) for impersonation via session transfer. + Requests a Session Transfer Token (STT) for impersonation via session transfer. - Runs a custom token exchange against the session_transfer audience. The - returned STT is opaque and single-use; hand it to - build_session_transfer_redirect and do not decode or store it. Does not - create or modify the current session. + The returned STT is opaque and single-use - pass it directly to + build_session_transfer_redirect and do not decode or store it. Args: - subject_token: Proof of which customer to impersonate (validated by your Action). - subject_token_type: The subject token type URI routing to your CTE Profile. - actor_token: The acting party's token; optional. Defaults to the agent - session's ID token. - actor_token_type: Type URI of the actor token; defaults to the ID token URN. - scope: Space-delimited list of scopes (optional). + subject_token: Proof of which customer to impersonate (validated by your CTE Action). + subject_token_type: Token type URI routing to your CTE Profile. + actor_token: The acting party's token. Defaults to the agent session's ID token. + actor_token_type: Type URI of the actor token. Defaults to the ID token URN. + scope: Space-delimited scopes (optional). organization: Organization identifier (optional). - store_options: Options passed to the State Store. Only required for - Multiple Custom Domains, where the domain resolver needs the - incoming request. Also used to read the agent session for the actor. + store_options: Must include request and response to read the agent session. Returns: - The SessionTransferTokenResult containing the STT and its metadata. + SessionTransferTokenResult containing the STT and its metadata. Raises: - CustomTokenExchangeError: If no actor can be resolved or the exchange - fails (see CustomTokenExchangeErrorCode). + CustomTokenExchangeError: If no actor can be resolved or the exchange fails. InvalidArgumentError: If organization is provided but blank. """ return await self.client.request_session_transfer_token( @@ -267,10 +312,9 @@ def build_session_transfer_redirect( """ Builds the redirect URL that hands the STT to the target app's login URL. - target_login_url must be a trusted, app-controlled absolute https URL (http - is allowed only for localhost/loopback). The STT is a single-use credential - and must not leak to an untrusted host, so never derive this URL from - untrusted input (such as a user-supplied returnTo). + target_login_url must be a trusted, app-controlled absolute https URL + (http is allowed only for localhost/loopback) - the STT is a single-use + credential and must not leak to an untrusted host. Args: target_login_url: The target app's login URL (absolute, https). @@ -278,8 +322,7 @@ def build_session_transfer_redirect( organization: Organization identifier to forward (optional). Returns: - A URL string with session_transfer_token (and organization) as query - parameters. + URL string with session_transfer_token (and organization) as query parameters. Raises: MissingRequiredArgumentError: If target_login_url is missing or blank. From 38f9c23cb56594bae8086449a00fea005c08746f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 10:49:52 +0530 Subject: [PATCH 09/18] refactor: restore pre-existing comments and docstrings to main versions --- src/auth0_fastapi/auth/auth_client.py | 86 ++++++++++++------- src/auth0_fastapi/errors/__init__.py | 32 ++++--- src/auth0_fastapi/server/routes.py | 63 ++++++++++++-- .../stores/cookie_transaction_store.py | 11 ++- .../stores/stateful_state_store.py | 18 ++-- .../stores/stateless_state_store.py | 14 ++- src/auth0_fastapi/util/__init__.py | 4 + 7 files changed, 170 insertions(+), 58 deletions(-) diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 9d3df40..d98a118 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -1,4 +1,4 @@ - +# Imported from auth0-server-python from typing import Optional from auth0_server_python.auth_server.server_client import ServerClient @@ -35,16 +35,20 @@ def __init__( transaction_store=None, ): self.config = config + # Build the redirect URI based on the provided app_base_url redirect_uri = f"{str(config.app_base_url).rstrip('/')}/auth/callback" + # Use provided state_store or default to cookie implementation if state_store is None: state_store = StatelessStateStore( config.secret, cookie_name="_a0_session", expiration=config.session_expiration) + # Use provided transaction_store or default to an cookie implementation if transaction_store is None: transaction_store = CookieTransactionStore( config.secret, cookie_name="_a0_tx") - # MCD resolves the domain per-request, so redirect_uri cannot be set here + # When domain is callable (MCD), don't hardcode redirect_uri in authorization_params + # It will be set dynamically per-request based on the incoming host auth_params = { "audience": config.audience, **(config.authorization_params or {}), @@ -53,10 +57,10 @@ def __init__( auth_params["redirect_uri"] = redirect_uri self.client = ServerClient( - domain=config.domain, + domain=config.domain, # Can be str or callable client_id=config.client_id, client_secret=config.client_secret, - redirect_uri=redirect_uri, + redirect_uri=redirect_uri, # Default fallback secret=config.secret, transaction_store=transaction_store, state_store=state_store, @@ -226,17 +230,23 @@ async def custom_token_exchange( store_options: dict = None, ) -> TokenExchangeResponse: """ - Exchanges a subject token for Auth0 tokens via RFC 8693 without creating a session. + Performs an RFC 8693 token exchange for the given subject token. + Does not create or modify the current session. Args: - options: Exchange configuration. - store_options: Store options. Required for Multiple Custom Domain domain resolution. + options: Subject token details and exchange parameters + (subject_token, subject_token_type, audience, scope, + organization, authorization_params). + store_options: Optional options passed to the Transaction and State + Store. Only required when using Multiple Custom Domains, where + the domain resolver needs the incoming request. Returns: - TokenExchangeResponse. + The raw TokenExchangeResponse (access_token, expires_in). Raises: - CustomTokenExchangeError: If the exchange fails. + CustomTokenExchangeError: If the exchange fails or the subject + token parameters are invalid (see CustomTokenExchangeErrorCode). """ return await self.client.custom_token_exchange(options, store_options=store_options) @@ -246,18 +256,26 @@ async def login_with_custom_token_exchange( store_options: dict = None, ) -> LoginWithCustomTokenExchangeResult: """ - Exchanges a subject token for Auth0 tokens via RFC 8693 and establishes a session. + Performs an RFC 8693 token exchange for the given subject token and + establishes a session for the resulting user. Args: - options: Exchange configuration. - store_options: Must include request and response to write the session cookie. + options: Subject token details and exchange parameters + (subject_token, subject_token_type, audience, scope, + organization, authorization_params). + store_options: Options passed to the Transaction and State Store. + Must include {"request": request, "response": response} so the + session cookie can be written on response. Returns: - LoginWithCustomTokenExchangeResult. + The LoginWithCustomTokenExchangeResult containing the session state + (including the resulting user). Raises: - CustomTokenExchangeError: If the exchange fails. - ValueError: If store_options is missing the response. + CustomTokenExchangeError: If the exchange fails or the subject + token parameters are invalid (see CustomTokenExchangeErrorCode). + ValueError: If store_options is missing the response needed to + write the session cookie. """ return await self.client.login_with_custom_token_exchange(options, store_options=store_options) @@ -272,25 +290,31 @@ async def request_session_transfer_token( store_options: dict = None, ) -> SessionTransferTokenResult: """ - Requests a Session Transfer Token (STT) for impersonation via session transfer. + Mints a Session Transfer Token (STT) for impersonation via session transfer. - The returned STT is opaque and single-use - pass it directly to - build_session_transfer_redirect and do not decode or store it. + Runs a custom token exchange against the session_transfer audience. The + returned STT is opaque and single-use; hand it to + build_session_transfer_redirect and do not decode or store it. Does not + create or modify the current session. Args: - subject_token: Proof of which customer to impersonate (validated by your CTE Action). - subject_token_type: Token type URI routing to your CTE Profile. - actor_token: The acting party's token. Defaults to the agent session's ID token. - actor_token_type: Type URI of the actor token. Defaults to the ID token URN. - scope: Space-delimited scopes (optional). + subject_token: Proof of which customer to impersonate (validated by your Action). + subject_token_type: The subject token type URI routing to your CTE Profile. + actor_token: The acting party's token; optional. Defaults to the agent + session's ID token. + actor_token_type: Type URI of the actor token; defaults to the ID token URN. + scope: Space-delimited list of scopes (optional). organization: Organization identifier (optional). - store_options: Must include request and response to read the agent session. + store_options: Options passed to the State Store. Only required for + Multiple Custom Domains, where the domain resolver needs the + incoming request. Also used to read the agent session for the actor. Returns: - SessionTransferTokenResult containing the STT and its metadata. + The SessionTransferTokenResult containing the STT and its metadata. Raises: - CustomTokenExchangeError: If no actor can be resolved or the exchange fails. + CustomTokenExchangeError: If no actor can be resolved or the exchange + fails (see CustomTokenExchangeErrorCode). InvalidArgumentError: If organization is provided but blank. """ return await self.client.request_session_transfer_token( @@ -312,9 +336,10 @@ def build_session_transfer_redirect( """ Builds the redirect URL that hands the STT to the target app's login URL. - target_login_url must be a trusted, app-controlled absolute https URL - (http is allowed only for localhost/loopback) - the STT is a single-use - credential and must not leak to an untrusted host. + target_login_url must be a trusted, app-controlled absolute https URL (http + is allowed only for localhost/loopback). The STT is a single-use credential + and must not leak to an untrusted host, so never derive this URL from + untrusted input (such as a user-supplied returnTo). Args: target_login_url: The target app's login URL (absolute, https). @@ -322,7 +347,8 @@ def build_session_transfer_redirect( organization: Organization identifier to forward (optional). Returns: - URL string with session_transfer_token (and organization) as query parameters. + A URL string with session_transfer_token (and organization) as query + parameters. Raises: MissingRequiredArgumentError: If target_login_url is missing or blank. diff --git a/src/auth0_fastapi/errors/__init__.py b/src/auth0_fastapi/errors/__init__.py index d207b8a..4a8babc 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -1,3 +1,4 @@ +#Imported from auth0-server-python from auth0_server_python.error import ( AccessTokenError, AccessTokenForConnectionError, @@ -16,32 +17,37 @@ class ConfigurationError(Auth0Error): - """Invalid SDK configuration.""" - + """ + Error raised when an invalid configuration is used. + """ code = "configuration_error" def __init__(self, message=None): - super().__init__(message or "Invalid SDK configuration.") + super().__init__(message or "An invalid configuration was provided.") self.name = "ConfigurationError" def auth0_exception_handler(request: Request, exc: Auth0Error): - """Maps Auth0 SDK errors to HTTP status codes.""" + """ + Exception handler for Auth0 SDK errors. + Maps different Auth0 errors to appropriate HTTP status codes. + """ + # Set a default status code status_code = 400 if isinstance(exc, MissingTransactionError): - status_code = 404 + status_code = 404 # Not Found elif isinstance(exc, MissingRequiredArgumentError): - status_code = 422 + status_code = 422 # Unprocessable Entity elif isinstance(exc, IssuerValidationError): - status_code = 401 + status_code = 401 # Unauthorized - token issuer mismatch elif isinstance(exc, ApiError): - status_code = 502 + status_code = 502 # Bad Gateway, indicates an upstream error elif isinstance(exc, AccessTokenError): - status_code = 401 + status_code = 401 # Unauthorized elif isinstance(exc, BackchannelLogoutError): - status_code = 400 + status_code = 400 # Bad Request elif isinstance(exc, AccessTokenForConnectionError): - status_code = 400 + status_code = 400 # Bad Request return JSONResponse( status_code=status_code, @@ -52,5 +58,7 @@ def auth0_exception_handler(request: Request, exc: Auth0Error): ) def register_exception_handlers(app): - """Registers the Auth0 exception handler with the FastAPI app.""" + """ + Register all Auth0-related exception handlers with the FastAPI app. + """ app.add_exception_handler(Auth0Error, auth0_exception_handler) diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index a6f5e7e..3676650 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -12,7 +12,10 @@ def get_auth_client(request: Request) -> AuthClient: - """Dependency to retrieve the AuthClient from app state.""" + """ + Dependency function to retrieve the AuthClient instance. + Assumes the client is set on the FastAPI application state. + """ auth_client = request.app.state.auth_client if not auth_client: raise HTTPException( @@ -25,6 +28,9 @@ def register_auth_routes(router: APIRouter, config: Auth0Config): Conditionally register auth routes based on config.mount_routes and config.mount_connect_routes. """ if config.mount_connect_routes and config.mount_connected_account_routes: + # Connect routes uses the legacy account linking flow for token vault + # Connects Accounts is the preferred mechanism + # Both mount the `/auth/connect` route to initiate the flow raise ConfigurationError( "'mount_connect_routes' and 'mount_connected_account_routes' cannot be used together.") @@ -35,12 +41,20 @@ async def login( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - """Initiates the Auth0 login flow and redirects to the authorization URL.""" + """ + Endpoint to initiate the login process. + Optionally accepts a 'return_to' query parameter and passes it as part of the app state. + Redirects the user to the Auth0 authorization URL. + + When domain is a callable (MCD), the redirect_uri is built dynamically + from the request host to ensure proper domain handling. + """ return_to: Optional[str] = request.query_params.get("returnTo") authorization_params = {k: v for k, v in request.query_params.items() if k not in [ "returnTo"]} + # Build dynamic redirect_uri from request host when domain is callable if callable(auth_client.config.domain): base_url = build_request_base_url(request) authorization_params["redirect_uri"] = f"{base_url}/auth/callback" @@ -59,7 +73,11 @@ async def callback( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - """Handles the Auth0 callback and redirects to the post-login destination.""" + """ + Endpoint to handle the callback after Auth0 authentication. + Processes the callback URL and completes the login or connected account flow. + Redirects the user to a post-login URL based on appState or a default. + """ full_callback_url = str(request.url) try: @@ -72,15 +90,20 @@ async def callback( session_data = await auth_client.complete_login( full_callback_url, store_options={"request": request, "response": response}) + # Extract the returnTo URL from the appState if available. app_state = session_data.get("app_state", {}) except Exception as e: raise HTTPException(status_code=400, detail=str(e)) + + # Extract the returnTo URL from the appState if available. return_to = app_state.get("returnTo") + # Build dynamic default_redirect from request host if domain is callable if callable(auth_client.config.domain): default_redirect = build_request_base_url(request) else: + # Assuming config is stored on app.state default_redirect = auth_client.config.app_base_url safe_redirect = to_safe_redirect(return_to, default_redirect) if return_to else str(default_redirect) @@ -92,9 +115,16 @@ async def logout( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): - """Clears the session and redirects to the Auth0 logout endpoint.""" + """ + Endpoint to handle logout. + Clears the session cookie (if applicable) and generates a logout URL, + then redirects the user to Auth0's logout endpoint. + + For MCD, builds dynamic returnTo URL based on incoming request host. + """ return_to: Optional[str] = request.query_params.get("returnTo") try: + # Build dynamic default_redirect from request host if domain is callable if callable(auth_client.config.domain): default_redirect = build_request_base_url(request) else: @@ -102,7 +132,7 @@ async def logout( logout_url = await auth_client.logout( return_to=return_to or default_redirect, - store_options={"request": request, "response": response}, + store_options={"request": request, "response": response}, # Pass request for MCD ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) @@ -114,7 +144,11 @@ async def backchannel_logout( request: Request, auth_client: AuthClient = Depends(get_auth_client), ): - """Processes a backchannel logout notification and returns 204 on success.""" + """ + Endpoint to process backchannel logout notifications. + Expects a form-encoded body with a 'logout_token'. + Returns 204 No Content on success. + """ body = await request.form() logout_token = body.get("logout_token") if not logout_token: @@ -140,7 +174,10 @@ async def connect_account( return_to: str = Query(default=None, alias="returnTo"), auth_client: AuthClient = Depends(get_auth_client), ): - """Initiates the connected account flow and redirects to the Auth0 connect URL.""" + """ + Endpoint to initiate the connect account flow for linking a third-party account to the user's profile. + Redirects the user to the Auth0 connect account URL. + """ authorization_params = { k: v for k, v in request.query_params.items() if k not in ["connection", "returnTo", "scopes"]} @@ -165,6 +202,7 @@ async def connect( auth_client: AuthClient = Depends(get_auth_client), ): + # Extract query parameters (connection, connectionScope, returnTo) connection = connection or request.query_params.get("connection") connection_scope = connectionScope or request.query_params.get( "connectionScope") @@ -180,10 +218,13 @@ async def connect( sanitized_return_to = to_safe_redirect( dangerous_return_to or "/", auth_client.config.app_base_url) + # Create the callback URL for linking callback_path = "/auth/connect/callback" redirect_uri = create_route_url( callback_path, auth_client.config.app_base_url) + # Call the startLinkUser method on our AuthClient. This method should accept parameters similar to: + # connection, connectionScope, authorizationParams (with redirect_uri), and app_state. link_user_url = await auth_client.start_link_user({ "connection": connection, "connectionScope": connection_scope, @@ -203,6 +244,7 @@ async def connect_callback( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): + # Use the full URL from the callback callback_url = str(request.url) try: result = await auth_client.complete_link_user( @@ -212,6 +254,7 @@ async def connect_callback( except Exception as e: raise HTTPException(status_code=400, detail=str(e)) + # Retrieve the returnTo parameter from app_state if available return_to = result.get("app_state", {}).get("returnTo") app_base_url = auth_client.config.app_base_url @@ -228,6 +271,7 @@ async def unconnect( auth_client: AuthClient = Depends(get_auth_client), ): + # Extract query parameters (connection, connectionScope, returnTo) connection = connection or request.query_params.get("connection") dangerous_return_to = returnTo or request.query_params.get( "returnTo") @@ -241,10 +285,13 @@ async def unconnect( sanitized_return_to = to_safe_redirect( dangerous_return_to or "/", auth_client.config.app_base_url) + # Create the callback URL for linking callback_path = "/auth/unconnect/callback" redirect_uri = create_route_url( callback_path, auth_client.config.app_base_url) + # Call the startLinkUser method on our AuthClient. This method should accept parameters similar to: + # connection, connectionScope, authorizationParams (with redirect_uri), and app_state. link_user_url = await auth_client.start_unlink_user({ "connection": connection, "authorization_params": { @@ -263,6 +310,7 @@ async def unconnect_callback( response: Response, auth_client: AuthClient = Depends(get_auth_client), ): + # Use the full URL from the callback callback_url = str(request.url) try: result = await auth_client.complete_unlink_user( @@ -272,6 +320,7 @@ async def unconnect_callback( except Exception as e: raise HTTPException(status_code=400, detail=str(e)) + # Retrieve the returnTo parameter from appState if available return_to = result.get("app_state", {}).get("returnTo") app_base_url = auth_client.config.app_base_url diff --git a/src/auth0_fastapi/stores/cookie_transaction_store.py b/src/auth0_fastapi/stores/cookie_transaction_store.py index 6bff6f5..56a2cb9 100644 --- a/src/auth0_fastapi/stores/cookie_transaction_store.py +++ b/src/auth0_fastapi/stores/cookie_transaction_store.py @@ -1,12 +1,18 @@ from typing import Any, Optional from auth0_server_python.auth_types import TransactionData + +#Imported from auth0-server-python from auth0_server_python.store.abstract import TransactionStore from fastapi import Request, Response class CookieTransactionStore(TransactionStore): - """Cookie-backed transaction store. Requires request and response in store_options.""" + """ + Transaction store implementation that uses a cookie to store transaction data. + This store expects the FastAPI Request and Response objects to be provided in the + store_options parameter. + """ def __init__(self, secret: str, cookie_name: str = "_a0_tx"): super().__init__({"secret": secret}) self.cookie_name = cookie_name @@ -26,7 +32,9 @@ async def set( response: Response = options["response"] + # Encrypt the transaction data using the abstract store method: encrypted_value = self.encrypt(identifier, value.model_dump()) + # Set cookie with a short max_age (e.g., 60 seconds for transactions) response.set_cookie( key=self.cookie_name, value=encrypted_value, @@ -53,6 +61,7 @@ async def get( return None try: + # Decrypt the stored value using the abstract store's decrypt method: decrypted_data = self.decrypt(identifier, encrypted_value) return TransactionData.model_validate(decrypted_data) except Exception: diff --git a/src/auth0_fastapi/stores/stateful_state_store.py b/src/auth0_fastapi/stores/stateful_state_store.py index fe76265..e07f9de 100644 --- a/src/auth0_fastapi/stores/stateful_state_store.py +++ b/src/auth0_fastapi/stores/stateful_state_store.py @@ -1,6 +1,8 @@ from typing import Any, Optional from auth0_server_python.auth_types import StateData + +#Imported from auth0-server-python from auth0_server_python.store.abstract import StateStore from fastapi import Response @@ -8,14 +10,17 @@ class StatefulStateStore(StateStore): - """Stateful session store backed by a persistent store (Redis, database). Tracks session ID in a cookie.""" + """ + A state store implementation that persists session data in a backend store + (for example, Redis or a database). It uses a cookie to keep track of the session ID. + The underlying session store must implement asynchronous get, set, delete, and keys methods. + """ def __init__(self, secret: str, store: Any, cookie_name: str = "_a0_session", expiration: int = 259200): """ - Args: - secret: Secret for encryption. - store: Persistent session store (e.g., a Redis client wrapper). - cookie_name: Name of the cookie holding the session identifier. - expiration: Session expiration in seconds. + :param secret: Secret for encryption (if needed) + :param store: The persistent session store (e.g., a Redis client wrapper) + :param cookie_name: Name of the cookie holding the session identifier + :param expiration: Session expiration time in seconds """ self.secret = secret self.store = store @@ -37,6 +42,7 @@ async def set( raise ValueError("Response object is required in store options for stateful storage.") response: Response = options["response"] + # Store the JSON representation. In a real implementation, encrypt if needed. data = state.model_dump_json() await self.store.set(identifier, data, expire=self.expiration) response.set_cookie( diff --git a/src/auth0_fastapi/stores/stateless_state_store.py b/src/auth0_fastapi/stores/stateless_state_store.py index 2c6ade9..fabfcf7 100644 --- a/src/auth0_fastapi/stores/stateless_state_store.py +++ b/src/auth0_fastapi/stores/stateless_state_store.py @@ -1,6 +1,8 @@ from typing import Any, Optional, Union from auth0_server_python.auth_types import StateData + +#Imported from auth0-server-python from auth0_server_python.store.abstract import StateStore from fastapi import Response @@ -16,11 +18,12 @@ def __init__(self, secret: str, cookie_name: str = "_a0_session", expiration: in self.expiration = expiration self.max_cookie_size = 4096 + # Default cookie options similar to Fastify's cookie options self.cookie_options = { "httponly": True, "samesite": "lax", "path": "/", - "secure": True, + "secure": True, # or set to "auto" if preferred "max_age": expiration, } @@ -42,9 +45,12 @@ async def set( state_dict = state.dict() else: state_dict = state + # Encrypt the transaction data using the abstract store method: encrypted_data = self.encrypt(identifier, state_dict) + # Calculate chunk size, ensuring space for the key name and additional characters chunk_size = self.max_cookie_size - len(self.cookie_name) - 10 cookies = {} + # Split data into chunks and store in the response cookies for i in range(0, len(encrypted_data), chunk_size): chunk_name = f"{self.cookie_name}_{i // chunk_size}" chunk_value = encrypted_data[i:i + chunk_size] @@ -74,6 +80,7 @@ async def get( request = options["request"] session_parts = [] + # Extract all cookies that match cookie_prefix for key, value in request.cookies.items(): if key.startswith(self.cookie_name): index = int(key.split("_")[-1]) @@ -81,12 +88,13 @@ async def get( if not session_parts: return "" - session_parts.sort() + session_parts.sort() # Sort by index full_encoded_data = "".join(part[1] for part in session_parts) if not full_encoded_data: return None try: + # Decrypt the stored value using the abstract store's decrypt method: decrypted_data = self.decrypt(identifier, full_encoded_data) return decrypted_data except Exception: @@ -105,8 +113,10 @@ async def delete( raise ValueError("Response object is required in store options for stateless storage.") response: Response = options["response"] + # Delete the base cookie if it exists response.delete_cookie(key=self.cookie_name) + # Delete potential cookie chunks (assume a max number of chunks, e.g., 20) for i in range(20): chunk_key = f"{self.cookie_name}_{i}" response.delete_cookie(key=chunk_key) diff --git a/src/auth0_fastapi/util/__init__.py b/src/auth0_fastapi/util/__init__.py index 20cbb76..bf1022b 100644 --- a/src/auth0_fastapi/util/__init__.py +++ b/src/auth0_fastapi/util/__init__.py @@ -51,11 +51,13 @@ def to_safe_redirect(dangerous_redirect: str, safe_base_url: str) -> Optional[st A safe redirect URL string if the origins match, or None otherwise. """ try: + # Ensure safe_base_url is a string. safe_base_url_str = str(safe_base_url) route_url = create_route_url(dangerous_redirect, safe_base_url_str) except Exception: return None + # Build origins from string values safe_origin = urlparse(safe_base_url_str).scheme + \ "://" + urlparse(safe_base_url_str).netloc route_origin = urlparse(route_url).scheme + "://" + \ @@ -85,6 +87,7 @@ def normalize_url(value: str) -> str: parsed = urlparse(value) + # Lowercase scheme and host scheme = (parsed.scheme or "https").lower() if scheme == "http": scheme = "https" @@ -93,6 +96,7 @@ def normalize_url(value: str) -> str: if not host: return "" + # Remove default port port = parsed.port if port and ((scheme == "https" and port == 443) or (scheme == "http" and port == 80)): port = None From acada4a7b1820a754c8c82ee2c7b760614d6671d Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 10:51:50 +0530 Subject: [PATCH 10/18] refactor: restore pre-existing comments in test_routes.py --- src/auth0_fastapi/test/test_routes.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index 2cb0c0e..c94b39d 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -297,10 +297,12 @@ async def test_backchannel_logout_valid_token(self, mock_auth_client): mock_request = Mock() valid_logout_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0LmF1dGgwLmNvbSJ9.signature" + # Mock request body mock_request.form = AsyncMock(return_value={"logout_token": valid_logout_token}) mock_auth_client.handle_backchannel_logout.return_value = None + # Simulate backchannel logout endpoint logic body = await mock_request.form() logout_token = body.get("logout_token") @@ -314,6 +316,7 @@ async def test_backchannel_logout_missing_token(self, mock_auth_client): mock_request = Mock() mock_request.form = AsyncMock(return_value={}) + # Simulate backchannel logout endpoint logic body = await mock_request.form() logout_token = body.get("logout_token") @@ -333,6 +336,7 @@ async def test_backchannel_logout_invalid_token(self, mock_auth_client): mock_auth_client.handle_backchannel_logout.side_effect = Exception("Invalid JWT token") + # Simulate backchannel logout endpoint logic body = await mock_request.form() logout_token = body.get("logout_token") From 27ce181f5cb42a2db67e25c0a16d52a845d616e0 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 10:57:32 +0530 Subject: [PATCH 11/18] docs: restore deleted docstrings and code comments --- examples/CustomTokenExchange.md | 4 +++- src/auth0_fastapi/test/test_auth_client.py | 6 ++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 1d1bc19..3582512 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -50,6 +50,7 @@ async def exchange_token(request: Request, response: Response): ```python from auth0_server_python.auth_types import CustomTokenExchangeOptions +# No FastAPI Request/Response involved — e.g. a background worker result = await auth_client.custom_token_exchange( CustomTokenExchangeOptions( subject_token="service-token", @@ -80,7 +81,8 @@ async def token_exchange_login(request: Request, response: Response): ), store_options={"request": request, "response": response}, ) - + # The session cookie is now set on `response`. Subsequent requests + # can use `Depends(auth_client.require_session)` as usual. user = result.state_data["user"] return {"user": user} ``` diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index f82328a..96e0d76 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -635,6 +635,7 @@ class TestCustomTokenExchange: @pytest.mark.asyncio async def test_custom_token_exchange_success(self, auth_client, mock_request, mock_response): + """Test that custom_token_exchange delegates to the underlying client and returns its result.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -661,6 +662,7 @@ async def test_custom_token_exchange_success(self, auth_client, mock_request, mo @pytest.mark.asyncio async def test_custom_token_exchange_does_not_touch_session(self, auth_client): + """Test that custom_token_exchange never calls get_session or writes to the state store.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -678,6 +680,7 @@ async def test_custom_token_exchange_does_not_touch_session(self, auth_client): @pytest.mark.asyncio async def test_custom_token_exchange_error_propagates(self, auth_client): + """Test that CustomTokenExchangeError from the underlying client is not swallowed or wrapped.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -711,6 +714,7 @@ async def test_custom_token_exchange_organization_forwarded(self, auth_client): @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): + """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" options = LoginWithCustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:corporate-idp-token", @@ -739,6 +743,7 @@ async def test_login_with_custom_token_exchange_success(self, auth_client, mock_ async def test_login_with_custom_token_exchange_passes_store_options_for_session_write( self, auth_client, mock_request, mock_response ): + """Test that request and response are forwarded so the state store can write the session cookie.""" options = LoginWithCustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:corporate-idp-token", @@ -762,6 +767,7 @@ async def test_login_with_custom_token_exchange_passes_store_options_for_session @pytest.mark.asyncio async def test_login_with_custom_token_exchange_missing_response_raises_value_error(self, auth_client): + """Test that omitting response in store_options surfaces the ValueError from the state store.""" options = LoginWithCustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:corporate-idp-token", From 7b24488178895c48d12184b593e78310a0c3d045 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 10:58:37 +0530 Subject: [PATCH 12/18] docs: restore HTTP 400 status code in error handling description --- examples/CustomTokenExchange.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 3582512..12c6855 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -154,7 +154,7 @@ result = await auth_client.login_with_custom_token_exchange( ## 5. Error Handling -Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP JSON response automatically: +Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP `400` JSON response automatically: ```python from auth0_fastapi.errors import register_exception_handlers From 9122ca583ad1a19e9fd540f9385c01e0e8517c6f Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 11:03:23 +0530 Subject: [PATCH 13/18] docs: revert convention changes in MultipleCustomDomains.md --- examples/MultipleCustomDomains.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/MultipleCustomDomains.md b/examples/MultipleCustomDomains.md index 3dd8646..e490139 100644 --- a/examples/MultipleCustomDomains.md +++ b/examples/MultipleCustomDomains.md @@ -229,9 +229,9 @@ to work if the resolver returns the same Auth0 domain that was used for those le The SDK uses a three-tier fallback to determine the session's domain: -1. **`session.domain`** - new sessions created after MCD was enabled store this field. -2. **Static domain** - if a static `domain` string was configured, it is used as a fallback. -3. **User's issuer claim** - the hostname is extracted from the `iss` claim in the user's +1. **`session.domain`** — new sessions created after MCD was enabled store this field. +2. **Static domain** — if a static `domain` string was configured, it is used as a fallback. +3. **User's issuer claim** — the hostname is extracted from the `iss` claim in the user's ID token (e.g., `https://login.brand-1.com/` yields `login.brand-1.com`). This means legacy sessions created before MCD support will still work as long as the @@ -272,10 +272,10 @@ The SDK passes request headers to your domain resolver via `DomainResolverContex The SDK uses the resolved domain to fetch OIDC metadata and JWKS. If an attacker can influence the resolved domain, they could point the SDK at an OIDC provider they control. -**Always use a mapping or allowlist - never construct domains from raw header values:** +**Always use a mapping or allowlist — never construct domains from raw header values:** ```python -# Safe: allowlist lookup - unknown hosts fall back to default +# Safe: allowlist lookup — unknown hosts fall back to default DOMAIN_MAP = { "brand-1.yourapp.com": "login.brand-1.com", "brand-2.yourapp.com": "login.brand-2.com", @@ -287,7 +287,7 @@ async def domain_resolver(context: DomainResolverContext) -> str: ``` ```python -# Risky: constructs domain from raw input - attacker can influence resolved domain +# Risky: constructs domain from raw input — attacker can influence resolved domain async def domain_resolver(context: DomainResolverContext) -> str: host = context.request_headers.get("host", "").split(":")[0] subdomain = host.split(".")[0] @@ -302,7 +302,7 @@ Without a trusted proxy layer to validate these headers, an attacker can manipul ### Trust Forwarded Headers Only Behind a Proxy -If your application is directly exposed to the internet (not behind a reverse proxy), do not trust `x-forwarded-host` or `x-forwarded-proto` - any client can set these headers. +If your application is directly exposed to the internet (not behind a reverse proxy), do not trust `x-forwarded-host` or `x-forwarded-proto` — any client can set these headers. Only use forwarded headers when your application runs behind a trusted reverse proxy (nginx, AWS ALB, Cloudflare, etc.) that sets these headers and strips any client-provided values. From 5f10b820bad7615b3d2d1d8e6b2ab6a98d4fb456 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 11:06:55 +0530 Subject: [PATCH 14/18] revert: remove backchannel logout form body fix - out of scope for CTE PR --- src/auth0_fastapi/server/routes.py | 4 ++-- src/auth0_fastapi/test/test_routes.py | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/auth0_fastapi/server/routes.py b/src/auth0_fastapi/server/routes.py index 3676650..48a2e05 100644 --- a/src/auth0_fastapi/server/routes.py +++ b/src/auth0_fastapi/server/routes.py @@ -146,10 +146,10 @@ async def backchannel_logout( ): """ Endpoint to process backchannel logout notifications. - Expects a form-encoded body with a 'logout_token'. + Expects a JSON body with a 'logout_token'. Returns 204 No Content on success. """ - body = await request.form() + body = await request.json() logout_token = body.get("logout_token") if not logout_token: raise HTTPException( diff --git a/src/auth0_fastapi/test/test_routes.py b/src/auth0_fastapi/test/test_routes.py index c94b39d..9b96ad7 100644 --- a/src/auth0_fastapi/test/test_routes.py +++ b/src/auth0_fastapi/test/test_routes.py @@ -298,12 +298,12 @@ async def test_backchannel_logout_valid_token(self, mock_auth_client): valid_logout_token = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJ0ZXN0LmF1dGgwLmNvbSJ9.signature" # Mock request body - mock_request.form = AsyncMock(return_value={"logout_token": valid_logout_token}) + mock_request.json = AsyncMock(return_value={"logout_token": valid_logout_token}) mock_auth_client.handle_backchannel_logout.return_value = None # Simulate backchannel logout endpoint logic - body = await mock_request.form() + body = await mock_request.json() logout_token = body.get("logout_token") await mock_auth_client.handle_backchannel_logout(logout_token) @@ -314,10 +314,10 @@ async def test_backchannel_logout_valid_token(self, mock_auth_client): async def test_backchannel_logout_missing_token(self, mock_auth_client): """Test backchannel logout with missing logout_token.""" mock_request = Mock() - mock_request.form = AsyncMock(return_value={}) + mock_request.json = AsyncMock(return_value={}) # Simulate backchannel logout endpoint logic - body = await mock_request.form() + body = await mock_request.json() logout_token = body.get("logout_token") # Should handle missing token gracefully @@ -332,12 +332,12 @@ async def test_backchannel_logout_invalid_token(self, mock_auth_client): """Test backchannel logout with invalid JWT token.""" mock_request = Mock() invalid_token = "invalid.jwt.token" - mock_request.form = AsyncMock(return_value={"logout_token": invalid_token}) + mock_request.json = AsyncMock(return_value={"logout_token": invalid_token}) mock_auth_client.handle_backchannel_logout.side_effect = Exception("Invalid JWT token") # Simulate backchannel logout endpoint logic - body = await mock_request.form() + body = await mock_request.json() logout_token = body.get("logout_token") with pytest.raises(Exception) as exc_info: From 757816567e64b91a1cf2bd2d7ce1baf1335a7192 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Tue, 8 Sep 2026 11:20:46 +0530 Subject: [PATCH 15/18] test: fix and complete org-scoping test coverage for CTE --- src/auth0_fastapi/test/test_auth_client.py | 67 +++++++++++++--------- 1 file changed, 39 insertions(+), 28 deletions(-) diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 96e0d76..4d620d7 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -697,6 +697,7 @@ async def test_custom_token_exchange_error_propagates(self, auth_client): @pytest.mark.asyncio async def test_custom_token_exchange_organization_forwarded(self, auth_client): + """Test that organization inside CustomTokenExchangeOptions is forwarded to the underlying client.""" options = CustomTokenExchangeOptions( subject_token="external-token", subject_token_type="urn:acme:legacy-session-token", @@ -710,7 +711,7 @@ async def test_custom_token_exchange_organization_forwarded(self, auth_client): await auth_client.custom_token_exchange(options) - mock_exchange.assert_called_once_with(options, store_options=None) + assert mock_exchange.call_args.args[0].organization == "org_abc123" @pytest.mark.asyncio async def test_login_with_custom_token_exchange_success(self, auth_client, mock_request, mock_response): @@ -783,6 +784,26 @@ async def test_login_with_custom_token_exchange_missing_response_raises_value_er with pytest.raises(ValueError): await auth_client.login_with_custom_token_exchange(options, store_options={}) + @pytest.mark.asyncio + async def test_login_with_custom_token_exchange_organization_forwarded(self, auth_client): + """Test that organization inside LoginWithCustomTokenExchangeOptions is forwarded to the underlying client.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + organization="org_abc123", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user"}}, + ) + + await auth_client.login_with_custom_token_exchange(options) + + assert mock_login_exchange.call_args.args[0].organization == "org_abc123" + class TestSessionTransferToken: """Test Session Transfer Token (STT) impersonation-via-session-transfer wrappers.""" @@ -849,33 +870,6 @@ async def test_request_session_transfer_token_defaults_optional_args_to_none(sel assert call_kwargs["organization"] is None assert call_kwargs["store_options"] is None - @pytest.mark.asyncio - async def test_request_session_transfer_token_with_explicit_actor(self, auth_client): - """Test that an explicit actor_token is forwarded to the core without modification.""" - actor_token = "agent-id-token" - actor_token_type = "urn:ietf:params:oauth:token-type:id_token" - mock_result = SessionTransferTokenResult( - session_transfer_token="opaque-stt-value", - issued_token_type="urn:auth0:params:oauth:token-type:session_transfer_token", - expires_in=60, - ) - - with patch.object( - auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock - ) as mock_request_stt: - mock_request_stt.return_value = mock_result - - await auth_client.request_session_transfer_token( - subject_token="customer-proof-token", - subject_token_type="urn:acme:customer-session", - actor_token=actor_token, - actor_token_type=actor_token_type, - ) - - call_kwargs = mock_request_stt.call_args.kwargs - assert call_kwargs["actor_token"] == actor_token - assert call_kwargs["actor_token_type"] == actor_token_type - @pytest.mark.asyncio async def test_request_session_transfer_token_with_organization(self, auth_client): """Test that organization is forwarded to the core for org-scoped STT minting.""" @@ -980,3 +974,20 @@ def test_build_session_transfer_redirect_propagates_validation_error(self, auth_ with pytest.raises(InvalidArgumentError): auth_client.build_session_transfer_redirect("http://evil.example.com/login", result) + + @pytest.mark.asyncio + async def test_request_session_transfer_token_blank_organization_raises(self, auth_client): + """Test that a blank organization propagates InvalidArgumentError from the underlying client.""" + with patch.object( + auth_client.client, 'request_session_transfer_token', new_callable=AsyncMock + ) as mock_request_stt: + mock_request_stt.side_effect = InvalidArgumentError( + "organization", "organization must not be blank" + ) + + with pytest.raises(InvalidArgumentError): + await auth_client.request_session_transfer_token( + subject_token="customer-proof-token", + subject_token_type="urn:acme:customer-session", + organization=" ", + ) From 1006ad6672a3f0db081bd3b480869272d2e2f676 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 10 Sep 2026 18:29:53 +0530 Subject: [PATCH 16/18] docs: remove duplicate org rejection note from scoping section --- examples/CustomTokenExchange.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 12c6855..5a5a98a 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -138,8 +138,7 @@ The STT-specific error codes (`ACTOR_UNAVAILABLE`, raised client-side when no ac ## 4. Scoping the Exchange to an Organization Pass `organization` (an org ID like `org_abc123`, or an org name) to scope the exchange to a specific -Auth0 Organization. The SDK forwards it to Auth0. Auth0 rejects the exchange with `CustomTokenExchangeError` -if the subject is not a member of that organization. +Auth0 Organization. The SDK forwards it to Auth0 as-is. ```python result = await auth_client.login_with_custom_token_exchange( From e8b778093d7abcaf738004efe8b45b73c202eb39 Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 10 Sep 2026 18:31:07 +0530 Subject: [PATCH 17/18] docs: remove mid-sentence line break in org scoping section --- examples/CustomTokenExchange.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 5a5a98a..0705fe3 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -137,8 +137,7 @@ The STT-specific error codes (`ACTOR_UNAVAILABLE`, raised client-side when no ac ## 4. Scoping the Exchange to an Organization -Pass `organization` (an org ID like `org_abc123`, or an org name) to scope the exchange to a specific -Auth0 Organization. The SDK forwards it to Auth0 as-is. +Pass `organization` (an org ID like `org_abc123`, or an org name) to scope the exchange to a specific Auth0 Organization. The SDK forwards it to Auth0 as-is. ```python result = await auth_client.login_with_custom_token_exchange( From e5ebaf0938bf3e8f4164e8a939a47095db165e7e Mon Sep 17 00:00:00 2001 From: Sourav Basu Date: Thu, 10 Sep 2026 18:40:23 +0530 Subject: [PATCH 18/18] docs: clarify org rejection surfaces via SDK as CustomTokenExchangeError --- examples/CustomTokenExchange.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 0705fe3..c3bfe04 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -191,7 +191,7 @@ See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0 `INVALID_TOKEN_FORMAT` is raised client-side before any network call for an empty or whitespace-only `subject_token`, or one with a `"Bearer "` prefix. Other malformed-but-nonempty values (including a `subject_token_type` that isn't a valid URI) are not checked client-side and are sent to Auth0, which rejects them. -When `organization` is set and the subject is not a member, Auth0 rejects the exchange as `CustomTokenExchangeError`. +When `organization` is set and the subject is not a member, Auth0 rejects the exchange and the SDK surfaces it as `CustomTokenExchangeError`. ## 6. Token Type URIs