Skip to content

Commit 355a0ef

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) ## Security Improvements - Strict subject token validation (fail-fast on whitespace/Bearer prefix) - Case-insensitive reserved OAuth parameter denylist - 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 - 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 ## 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 link - Fixed misleading audience parameter documentation - Added note about token targeting without explicit audience ## 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 - 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) - 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 355a0ef

9 files changed

Lines changed: 707 additions & 220 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: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,71 @@ 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+
)
148+
149+
# Result contains access_token, expires_in, expires_at, and optionally id_token, refresh_token
150+
151+
asyncio.run(main())
152+
```
153+
154+
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.
155+
156+
If neither an explicit `audience` nor tenant/Action logic sets it, you may receive a token not targeted at your API.
157+
158+
#### Additional Parameters
159+
160+
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:
161+
162+
```python
163+
result = await api_client.get_token_by_exchange_profile(
164+
subject_token=subject_token,
165+
subject_token_type="urn:example:subject-token",
166+
audience="https://api.example.com",
167+
extra={
168+
"device_id": "device-12345",
169+
"session_id": "sess-abc"
170+
}
171+
)
172+
```
173+
174+
> [!WARNING]
175+
> 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.
176+
177+
**Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (JavaScript/TypeScript)
178+
179+
More info: https://auth0.com/docs/authenticate/custom-token-exchange
180+
116181
#### Requiring Additional Claims
117182

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

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

129-
### 5. DPoP Authentication
194+
### 6. DPoP Authentication
130195

131196
> [!NOTE]
132197
> 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)