Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
b8d245d
CTE Org Support Wrapper
rmad17 Aug 11, 2026
5f7f550
fix: scope CTE org support to parameter forwarding
rmad17 Aug 28, 2026
a5c298b
refactor: tighten comments, docstrings, and example prose
rmad17 Aug 30, 2026
378e1a1
feat: add request_session_transfer_token and build_session_transfer_r…
rmad17 Sep 7, 2026
eb40fad
refactor: strip narrating comments, em dashes, and cross-impl references
rmad17 Sep 7, 2026
650798e
fix: read backchannel logout token from form body per OIDC spec
rmad17 Sep 7, 2026
a48af53
Merge branch 'main' into feat/cte-org
rmad17 Sep 7, 2026
dbdee4e
fix: remove duplicate InvalidArgumentError import in test_auth_client
rmad17 Sep 7, 2026
7b8148e
refactor: restore original method docstrings in AuthClient
rmad17 Sep 7, 2026
38f9c23
refactor: restore pre-existing comments and docstrings to main versions
rmad17 Sep 8, 2026
acada4a
refactor: restore pre-existing comments in test_routes.py
rmad17 Sep 8, 2026
27ce181
docs: restore deleted docstrings and code comments
rmad17 Sep 8, 2026
7b24488
docs: restore HTTP 400 status code in error handling description
rmad17 Sep 8, 2026
9122ca5
docs: revert convention changes in MultipleCustomDomains.md
rmad17 Sep 8, 2026
5f10b82
revert: remove backchannel logout form body fix - out of scope for CT…
rmad17 Sep 8, 2026
7578165
test: fix and complete org-scoping test coverage for CTE
rmad17 Sep 8, 2026
1006ad6
docs: remove duplicate org rejection note from scoping section
rmad17 Sep 10, 2026
e8b7780
docs: remove mid-sentence line break in org scoping section
rmad17 Sep 10, 2026
e5ebaf0
docs: clarify org rejection surfaces via SDK as CustomTokenExchangeError
rmad17 Sep 10, 2026
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
35 changes: 26 additions & 9 deletions examples/CustomTokenExchange.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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:

Expand All @@ -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},
)
Expand All @@ -176,14 +191,16 @@ 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.

## 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
- [auth0-server-python: Custom Token Exchange](https://github.com/auth0/auth0-server-python/blob/main/examples/CustomTokenExchange.md)
2 changes: 1 addition & 1 deletion src/auth0_fastapi/auth/auth_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

# Imported from auth0-server-python
from typing import Optional

Expand Down Expand Up @@ -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,
Expand Down
106 changes: 104 additions & 2 deletions src/auth0_fastapi/test/test_auth_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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=" ",
)
Loading