diff --git a/README.md b/README.md index 9790477..4d53017 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ - **Fully Integrated Auth Flows**: Automatic routes for `/auth/login`, `/auth/logout`, `/auth/callback`, etc. - **Session-Based**: Uses secure cookies to store user sessions, either stateless (all data in cookie) or stateful (data in a database). +- **Custom Token Exchange**: Exchange tokens from external identity providers or legacy systems for Auth0 tokens, with or without establishing a session. - **Multiple Custom Domains (MCD)**: Support for applications using multiple custom domains on the same Auth0 tenant. - **Account Linking**: Optional routes for linking multiple social or username/password accounts into a single Auth0 profile. - **Backchannel Logout**: Receive logout tokens from Auth0 to invalidate sessions server-side. @@ -308,6 +309,10 @@ When an enterprise connection has "Use ID Token for Session Expiry" enabled, Aut For detailed behavior and how to read the value, see [examples/Sessions.md](./examples/Sessions.md). +### 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. + ## Feedback ### Contributing diff --git a/examples/CustomTokenExchange.md b/examples/CustomTokenExchange.md new file mode 100644 index 0000000..cdffc87 --- /dev/null +++ b/examples/CustomTokenExchange.md @@ -0,0 +1,155 @@ +# Custom Token Exchange + +Custom Token Exchange lets your FastAPI backend exchange a token from an external identity provider or legacy authentication system for Auth0 tokens, without a browser redirect. This implements **OAuth 2.0 Token Exchange** ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)). + +> **NOTE**: For configuration requirements on the Auth0 side (token-exchange profiles, Actions, reserved namespaces), see the [official Auth0 documentation](https://auth0.com/docs/authenticate/custom-token-exchange). + +`AuthClient` exposes two methods for this: + +| 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 | +| `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. + +## 1. Basic Token Exchange (no session) + +Exchange a token for Auth0 tokens without affecting the caller's session: + +```python +from fastapi import APIRouter, Request, Response +from auth0_server_python.auth_types import CustomTokenExchangeOptions + +from auth0_fastapi.auth.auth_client import AuthClient + +router = APIRouter() + +@router.post("/api/exchange") +async def exchange_token(request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="token-from-external-system", + subject_token_type="urn:acme:legacy-session-token", + audience="https://downstream-api.example.com", + scope="read:data write:data", + ), + store_options={"request": request, "response": response}, + ) + + return { + "access_token": result.access_token, + "expires_in": result.expires_in, + } +``` + +`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 + +# No FastAPI Request/Response involved — e.g. a background worker +result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token="service-token", + subject_token_type="urn:acme:service-token", + audience="https://downstream-api.example.com", + ) +) +``` + +## 2. Login with Custom Token Exchange (establishes a session) + +Exchange a token AND log the user into your FastAPI app: + +```python +from fastapi import APIRouter, Request, Response +from auth0_server_python.auth_types import LoginWithCustomTokenExchangeOptions + +router = APIRouter() + +@router.post("/auth/token-exchange") +async def token_exchange_login(request: Request, response: Response): + auth_client: AuthClient = request.app.state.auth_client + + result = await auth_client.login_with_custom_token_exchange( + LoginWithCustomTokenExchangeOptions( + subject_token="token-from-external-idp", + subject_token_type="urn:acme:corporate-idp-token", + ), + 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. + +Once this route completes, protected routes work immediately: + +```python +from fastapi import Depends + +@router.get("/api/me") +async def get_profile(session: dict = Depends(auth_client.require_session)): + return {"user": session["user"]} +``` + +> **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 + +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 + +register_exception_handlers(app) +``` + +Or handle it inline if a route needs custom behavior: + +```python +from fastapi import HTTPException +from auth0_fastapi.errors import CustomTokenExchangeError, CustomTokenExchangeErrorCode + +@router.post("/api/exchange") +async def exchange_token(request: Request, response: Response): + try: + result = await auth_client.custom_token_exchange( + CustomTokenExchangeOptions( + subject_token=request.headers.get("x-external-token", ""), + subject_token_type="urn:acme:legacy-session-token", + ), + store_options={"request": request, "response": response}, + ) + except CustomTokenExchangeError as e: + if e.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT: + raise HTTPException(status_code=400, detail="Invalid subject token") + raise + + return {"access_token": result.access_token} +``` + +### Common Error Codes + +See the [auth0-server-python Custom Token Exchange doc](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md#common-error-codes) for the full, up-to-date list of `CustomTokenExchangeErrorCode` values and what triggers each one. + +`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 + +`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`). + +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. + +## Additional Resources + +- [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 diff --git a/requirements.txt b/requirements.txt index 0006fdd..ebb9b18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -auth0-server-python>=1.0.0b7 +auth0-server-python>=1.0.0b11 fastapi>=0.115.11 pydantic>=2.12.5 uvicorn>=0.39.0 diff --git a/src/auth0_fastapi/auth/auth_client.py b/src/auth0_fastapi/auth/auth_client.py index 36e7280..f9468a3 100644 --- a/src/auth0_fastapi/auth/auth_client.py +++ b/src/auth0_fastapi/auth/auth_client.py @@ -6,8 +6,12 @@ from auth0_server_python.auth_types import ( CompleteConnectAccountResponse, ConnectAccountOptions, + CustomTokenExchangeOptions, + LoginWithCustomTokenExchangeOptions, + LoginWithCustomTokenExchangeResult, LogoutOptions, StartInteractiveLoginOptions, + TokenExchangeResponse, ) from fastapi import HTTPException, Request, Response, status @@ -219,3 +223,58 @@ async def require_session( raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Please log in") return session + + async def custom_token_exchange( + self, + options: CustomTokenExchangeOptions, + store_options: dict = None, + ) -> TokenExchangeResponse: + """ + Performs an RFC 8693 token exchange for the given subject token. + Does not create or modify the current 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. + + Returns: + The raw TokenExchangeResponse (access_token, expires_in). + + Raises: + 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) + + async def login_with_custom_token_exchange( + self, + options: LoginWithCustomTokenExchangeOptions, + store_options: dict = None, + ) -> LoginWithCustomTokenExchangeResult: + """ + Performs an RFC 8693 token exchange for the given subject token and + establishes a session for the resulting user. + + 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. + + Returns: + The LoginWithCustomTokenExchangeResult containing the session state + (including the resulting user). + + 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. + """ + 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 184bfeb..0e82368 100644 --- a/src/auth0_fastapi/errors/__init__.py +++ b/src/auth0_fastapi/errors/__init__.py @@ -5,6 +5,8 @@ ApiError, Auth0Error, BackchannelLogoutError, + CustomTokenExchangeError, + CustomTokenExchangeErrorCode, IssuerValidationError, MissingRequiredArgumentError, MissingTransactionError, diff --git a/src/auth0_fastapi/test/test_auth_client.py b/src/auth0_fastapi/test/test_auth_client.py index 606fe79..8d028c7 100644 --- a/src/auth0_fastapi/test/test_auth_client.py +++ b/src/auth0_fastapi/test/test_auth_client.py @@ -2,7 +2,15 @@ from unittest.mock import AsyncMock, Mock, patch import pytest -from auth0_server_python.auth_types import CompleteConnectAccountResponse, ConnectAccountOptions +from auth0_server_python.auth_types import ( + CompleteConnectAccountResponse, + ConnectAccountOptions, + CustomTokenExchangeOptions, + LoginWithCustomTokenExchangeOptions, + LoginWithCustomTokenExchangeResult, + TokenExchangeResponse, +) +from auth0_server_python.error import CustomTokenExchangeError, CustomTokenExchangeErrorCode from fastapi import HTTPException, Request, Response from auth0_fastapi.auth.auth_client import AuthClient @@ -619,3 +627,140 @@ async def domain_resolver(context): # Verify other config properties are accessible assert client.config.client_id == "test_client_id" assert str(client.config.app_base_url) == "https://example.com/" # Pydantic normalizes with trailing slash + + +class TestCustomTokenExchange: + """Test Custom Token Exchange (RFC 8693) security and functionality.""" + + @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", + ) + mock_result = TokenExchangeResponse( + access_token="new_access_token", + token_type="Bearer", + expires_in=3600, + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.return_value = mock_result + + result = await auth_client.custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_exchange.assert_called_once_with( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + @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", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange, \ + patch.object(auth_client.client, 'get_session', new_callable=AsyncMock) as mock_get_session: + mock_exchange.return_value = TokenExchangeResponse( + access_token="token", token_type="Bearer", expires_in=3600 + ) + + await auth_client.custom_token_exchange(options) + + mock_get_session.assert_not_called() + + @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", + ) + + with patch.object(auth_client.client, 'custom_token_exchange', new_callable=AsyncMock) as mock_exchange: + mock_exchange.side_effect = CustomTokenExchangeError( + CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT, + "subject_token cannot be empty or whitespace-only", + ) + + with pytest.raises(CustomTokenExchangeError): + 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.""" + options = LoginWithCustomTokenExchangeOptions( + subject_token="external-token", + subject_token_type="urn:acme:corporate-idp-token", + ) + mock_result = LoginWithCustomTokenExchangeResult( + state_data={"user": {"sub": "test_user"}}, + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.return_value = mock_result + + result = await auth_client.login_with_custom_token_exchange( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + assert result == mock_result + mock_login_exchange.assert_called_once_with( + options, + store_options={"request": mock_request, "response": mock_response}, + ) + + @pytest.mark.asyncio + 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", + ) + + 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, + store_options={"request": mock_request, "response": mock_response}, + ) + + call_kwargs = mock_login_exchange.call_args.kwargs + assert call_kwargs['store_options']['request'] is mock_request + assert call_kwargs['store_options']['response'] is mock_response + + @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", + ) + + with patch.object( + auth_client.client, 'login_with_custom_token_exchange', new_callable=AsyncMock + ) as mock_login_exchange: + mock_login_exchange.side_effect = ValueError( + "Response object is required in store options for stateless storage." + ) + + with pytest.raises(ValueError): + await auth_client.login_with_custom_token_exchange(options, store_options={})