Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
155 changes: 155 additions & 0 deletions examples/CustomTokenExchange.md
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
59 changes: 59 additions & 0 deletions src/auth0_fastapi/auth/auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions src/auth0_fastapi/errors/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
ApiError,
Auth0Error,
BackchannelLogoutError,
CustomTokenExchangeError,
CustomTokenExchangeErrorCode,
IssuerValidationError,
MissingRequiredArgumentError,
MissingTransactionError,
Expand Down
Loading
Loading