diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md index 748a2c6..c3bfe04 100644 --- a/examples/CustomTokenExchange.md +++ b/examples/CustomTokenExchange.md @@ -8,10 +8,10 @@ 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. +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 @@ -81,14 +81,13 @@ 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: @@ -136,7 +135,22 @@ Impersonation is one principal acting as another, so the mint is the event worth The STT-specific error codes (`ACTOR_UNAVAILABLE`, raised client-side when no actor can be resolved; `SETACTOR_REQUIRED`; `SESSION_TRANSFER_DISABLED`) are on `CustomTokenExchangeErrorCode` and surface through the same handling as below. -## 4. Error Handling +## 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. + +```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}, +) +``` + +## 5. Error Handling Register the SDK's exception handler once, and `CustomTokenExchangeError` will be mapped to an HTTP `400` JSON response automatically: @@ -159,6 +173,7 @@ 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}, ) @@ -176,9 +191,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. -## 5. Token Type URIs +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 -`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. @@ -186,4 +203,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/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 810fa75..d98a118 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -1,4 +1,3 @@ - # Imported from auth0-server-python from typing import Optional @@ -316,6 +315,7 @@ async def request_session_transfer_token( Raises: 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( subject_token=subject_token, diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index d1647d1..4d620d7 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -11,12 +11,11 @@ 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 from auth0_fastapi.config import Auth0Config -from auth0_fastapi.errors import InvalidArgumentError @pytest.fixture @@ -696,6 +695,24 @@ 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 organization inside CustomTokenExchangeOptions is forwarded to the underlying client.""" + 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) + + 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): """Test that login_with_custom_token_exchange delegates to the underlying client and returns its result.""" @@ -767,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.""" @@ -833,6 +870,28 @@ 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_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_error_propagates(self, auth_client): """Test that ACTOR_UNAVAILABLE (and other CTE errors) from the client are not swallowed or wrapped.""" @@ -877,6 +936,32 @@ def test_build_session_transfer_redirect_delegates_and_returns(self, auth_client organization="org_123", ) + 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_propagates_validation_error(self, auth_client): """The redirect helper surfaces the core's URL validation instead of swallowing it, so a non-https target the docstring promises to reject reaches the caller. (The full validation @@ -889,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=" ", + )