Skip to content

Commit 976f554

Browse files
btiernayclaude
andcommitted
feat: add Custom Token Exchange support (RFC 8693)
Add comprehensive support for Custom Token Exchange via RFC 8693, enabling token exchange using Auth0 Token Exchange Profiles. This implementation has been validated against auth0-auth-js for security and behavioral alignment. ## Core Features - RFC 8693 OAuth 2.0 Token Exchange implementation - Token exchange via subject_token and subject_token_type - Support for optional audience, scope, and requested_token_type - Extra parameters support for custom profile/Action data - HTTP Basic authentication (client_secret_basic) - Configurable HTTP timeout (default 10 seconds) with explicit None check ## Security Improvements - Strict subject token validation (fail-fast on whitespace/Bearer prefix) - Case-insensitive reserved OAuth parameter denylist - Added subject_issuer to reserved parameters (RFC 8693) - DoS protection with 20-item array limit for extra parameters - Client credential requirement (confidential client only) - List item string conversion for extra parameters - Validated against auth0-auth-js implementation ## API Changes (Non-Breaking) - Add ApiClient.get_token_by_exchange_profile() method - Add GetTokenByExchangeProfileError exception class - Add timeout parameter to ApiClientOptions (default: 10.0 seconds) - Export GetTokenByExchangeProfileError in __init__.py ## Code Improvements - Extracted _apply_extra() helper for parameter validation - Case-insensitive reserved parameter checking - Fixed timeout handling with explicit None check (prevents 0.0 override) - Simplified verify_request token validation logic - Collapsed subject_token validation to avoid redundant strip() calls - Applied ValueError (vs Exception) for JSON parsing consistency - Module-level constants for token exchange parameters - Used last_form() helper throughout tests for cleaner code ## Documentation - Added Custom Token Exchange section to README - Documented confidential client requirement with link - Added security warnings for extra parameters - Replaced hard-coded namespace list with guidance and doc link - Fixed misleading audience parameter documentation - Added note about token targeting without explicit audience - Documented HTTP Basic authentication explicitly - Enhanced examples with optional parameters (scope, requested_token_type) - Added array limit note (20 values per key) - Updated ApiClientOptions docstring for both methods ## Testing - Added 88 comprehensive tests (86 existing + 2 new) - 85% code coverage maintained - Added freezegun for deterministic time testing - Parameterized tests with descriptive ids for better CI output - Pytest fixtures (api_client_confidential, mock_discovery, last_form) - Validation short-circuit test ensures fail-fast behavior - Test for MAX_ARRAY_VALUES_PER_KEY (DoS protection) - Test for case-insensitive reserved parameter checking - Used last_form() helper to eliminate duplicate parsing code - All tests passing with ruff linting checks ## Validation Implementation validated line-by-line against auth0-auth-js: - Subject token validation matches JS SDK behavior - Array size limits align (MAX_ARRAY_VALUES_PER_KEY = 20) - Reserved parameters match PARAM_DENYLIST (case-insensitive) - Added subject_issuer per RFC 8693 - Client authentication method (client_secret_basic) - Error handling and response parsing - Intentional improvements: fail-fast reserved params, expires_in return Related: https://github.com/auth0/auth0-auth-js 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a4c600c commit 976f554

9 files changed

Lines changed: 713 additions & 224 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,7 @@ setup.py
2222
test.py
2323
test-script.py
2424
.coverage
25-
coverage.xml
25+
coverage.xml
26+
27+
# IDE
28+
.idea/

README.md

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,74 @@ asyncio.run(main())
113113

114114
More info https://auth0.com/docs/secure/tokens/token-vault
115115

116+
### 5. Custom Token Exchange (Early Access)
117+
118+
> [!NOTE]
119+
> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access) for Enterprise customers. Please reach out to Auth0 support to get it enabled for your tenant.
120+
121+
This feature requires a [confidential client](https://auth0.com/docs/get-started/applications/confidential-and-public-applications#confidential-applications) (both `client_id` and `client_secret` must be configured).
122+
123+
Custom Token Exchange allows you to exchange a subject token for Auth0 tokens using RFC 8693. This is useful for:
124+
- Getting Auth0 tokens for another audience
125+
- Integrating external identity providers
126+
- Migrating to Auth0
127+
128+
```python
129+
import asyncio
130+
131+
from auth0_api_python import ApiClient, ApiClientOptions
132+
133+
async def main():
134+
api_client = ApiClient(ApiClientOptions(
135+
domain="<AUTH0_DOMAIN>",
136+
audience="<AUTH0_AUDIENCE>",
137+
client_id="<AUTH0_CLIENT_ID>",
138+
client_secret="<AUTH0_CLIENT_SECRET>",
139+
))
140+
141+
subject_token = "..." # Token from your legacy system or external source
142+
143+
result = await api_client.get_token_by_exchange_profile(
144+
subject_token=subject_token,
145+
subject_token_type="urn:example:subject-token",
146+
audience="https://api.example.com", # Optional - omit if your Action or tenant configuration sets the audience
147+
scope="openid profile email", # Optional
148+
requested_token_type="urn:ietf:params:oauth:token-type:access_token" # Optional
149+
)
150+
151+
# Result contains access_token, expires_in, expires_at, and optionally id_token, refresh_token
152+
153+
asyncio.run(main())
154+
```
155+
156+
**Important:**
157+
- Client authentication is sent via HTTP Basic (`client_id`/`client_secret`), not in the form body.
158+
- The `subject_token_type` must match a Token Exchange Profile configured in Auth0. This URI identifies which profile will process the exchange and **must not** use reserved OAuth namespaces (e.g., IETF or vendor namespaces like Auth0/Okta). See the [Custom Token Exchange documentation](https://auth0.com/docs/authenticate/custom-token-exchange) for naming guidance.
159+
- If neither an explicit `audience` nor tenant/Action logic sets it, you may receive a token not targeted at your API.
160+
161+
#### Additional Parameters
162+
163+
You can pass additional parameters for your Token Exchange Profile or Actions via the `extra` parameter. These are sent as form fields to Auth0 and may be inspected by Actions:
164+
165+
```python
166+
result = await api_client.get_token_by_exchange_profile(
167+
subject_token=subject_token,
168+
subject_token_type="urn:example:subject-token",
169+
audience="https://api.example.com",
170+
extra={
171+
"device_id": "device-12345",
172+
"session_id": "sess-abc"
173+
}
174+
)
175+
```
176+
177+
> [!WARNING]
178+
> Extra parameters are sent as form fields and may appear in logs. Do not include secrets or sensitive data. Reserved OAuth parameter names (like `grant_type`, `client_id`, `scope`) cannot be used and will raise an error. Arrays are supported but limited to 20 values per key to prevent abuse.
179+
180+
**Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (JavaScript/TypeScript)
181+
182+
More info: https://auth0.com/docs/authenticate/custom-token-exchange
183+
116184
#### Requiring Additional Claims
117185

118186
If your application demands extra claims, specify them with `required_claims`:
@@ -126,7 +194,7 @@ decoded_and_verified_token = await api_client.verify_access_token(
126194

127195
If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
128196

129-
### 5. DPoP Authentication
197+
### 6. DPoP Authentication
130198

131199
> [!NOTE]
132200
> This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant.

poetry.lock

Lines changed: 43 additions & 201 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ pytest-asyncio = "^0.25.3"
2424
pytest-mock = "^3.15.1"
2525
pytest-httpx = "^0.35.0"
2626
ruff = ">=0.1,<0.15"
27+
freezegun = "^1.5.5"
2728

2829
[tool.pytest.ini_options]
2930
addopts = "--cov=src --cov-report=term-missing:skip-covered --cov-report=xml"

src/auth0_api_python/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77

88
from .api_client import ApiClient
99
from .config import ApiClientOptions
10+
from .errors import GetTokenByExchangeProfileError
1011

1112
__all__ = [
1213
"ApiClient",
13-
"ApiClientOptions"
14+
"ApiClientOptions",
15+
"GetTokenByExchangeProfileError"
1416
]

0 commit comments

Comments
 (0)