Skip to content

Commit e3c17c3

Browse files
btiernayclaude
andcommitted
feat: add Custom Token Exchange support (RFC 8693)
Implements get_token_by_exchange_profile() method for exchanging subject tokens via Auth0 Token Exchange Profiles, following RFC 8693. Features: - Subject token exchange with strict validation (fail-fast on whitespace) - Support for optional audience, scope, and requested_token_type parameters - Extra parameters for Actions with reserved param validation (fail-fast) - DoS protection with 20-item array size limit for extra parameters - Returns access_token, expires_in, expires_at, and optional fields - HTTP Basic authentication with confidential client credentials - Comprehensive error handling with upstream status preservation - 10-second HTTP timeout to prevent hanging requests Implementation details: - Validates subject tokens strictly (rejects whitespace, Bearer prefix) - Fails fast when reserved OAuth parameters supplied in extras - Enforces MAX_ARRAY_VALUES_PER_KEY=20 for DoS protection - Preserves upstream 4xx error status codes - Module-level constants (TOKEN_EXCHANGE_GRANT_TYPE, RESERVED_PARAMS) - Case-insensitive header handling (normalizes to lowercase) - Precise error handling (ValueError for JSON parse errors) - Adds GetTokenByExchangeProfileError for validation failures Validation against auth0-auth-js: - Whitespace handling matches strict JS SDK behavior (fail-fast) - Array size limit matches JS SDK (20 items for DoS protection) - Reserved params list aligned with JS SDK implementation - Improves on JS SDK: fails fast on reserved params vs silent ignore - Enhances return value: includes both expires_in and expires_at Documentation: - Added Early Access feature note with confidential client requirement - Documented Token Exchange Profile URI matching and namespace rules - Security warning for extra parameters (form fields, no secrets) - Clarified audience optionality with inline example - Linked to auth0-auth-js companion SDK and Auth0 docs Tests: 24 new tests covering success paths, validation, error handling, and edge cases. All 84 tests passing with 85% coverage. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a4c600c commit e3c17c3

6 files changed

Lines changed: 594 additions & 14 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: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,69 @@ 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 to use the client's default 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 cannot use reserved OAuth namespaces (e.g., `urn:ietf:params:oauth:*`).
155+
156+
#### Additional Parameters
157+
158+
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:
159+
160+
```python
161+
result = await api_client.get_token_by_exchange_profile(
162+
subject_token=subject_token,
163+
subject_token_type="urn:example:subject-token",
164+
audience="https://api.example.com",
165+
extra={
166+
"device_id": "device-12345",
167+
"session_id": "sess-abc"
168+
}
169+
)
170+
```
171+
172+
> [!WARNING]
173+
> 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.
174+
175+
**Related SDKs:** [auth0-auth-js](https://github.com/auth0/auth0-auth-js) (JavaScript/TypeScript)
176+
177+
More info: https://auth0.com/docs/authenticate/custom-token-exchange
178+
116179
#### Requiring Additional Claims
117180

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

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

129-
### 5. DPoP Authentication
192+
### 6. DPoP Authentication
130193

131194
> [!NOTE]
132195
> 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.

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
]

src/auth0_api_python/api_client.py

Lines changed: 231 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import time
2-
from typing import Any, Optional
2+
from typing import Any, Optional, Union
33

44
import httpx
55
from authlib.jose import JsonWebKey, JsonWebToken
@@ -9,6 +9,7 @@
99
ApiError,
1010
BaseAuthError,
1111
GetAccessTokenForConnectionError,
12+
GetTokenByExchangeProfileError,
1213
InvalidAuthSchemeError,
1314
InvalidDpopProofError,
1415
MissingAuthorizationError,
@@ -24,6 +25,19 @@
2425
sha256_base64url,
2526
)
2627

28+
# Token Exchange constants
29+
TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105
30+
MAX_ARRAY_VALUES_PER_KEY = 20 # DoS protection for extra parameter arrays
31+
32+
# OAuth parameter denylist - parameters that cannot be overridden via extras
33+
RESERVED_PARAMS = frozenset([
34+
"grant_type", "client_id", "client_secret", "client_assertion",
35+
"client_assertion_type", "subject_token", "subject_token_type",
36+
"requested_token_type", "actor_token", "actor_token_type",
37+
"audience", "aud", "resource", "resources", "resource_indicator",
38+
"scope", "connection", "login_hint", "organization", "assertion",
39+
])
40+
2741

2842
class ApiClient:
2943
"""
@@ -78,6 +92,9 @@ async def verify_request(
7892
InvalidDpopProofError: If DPoP verification fails
7993
VerifyAccessTokenError: If access token verification fails
8094
"""
95+
# Normalize header keys to lowercase for robust access
96+
headers = {k.lower(): v for k, v in headers.items()}
97+
8198
authorization_header = headers.get("authorization", "")
8299
dpop_proof = headers.get("dpop")
83100

@@ -86,22 +103,20 @@ async def verify_request(
86103
raise self._prepare_error(
87104
InvalidAuthSchemeError("")
88105
)
89-
else :
106+
else:
90107
raise self._prepare_error(MissingAuthorizationError())
91108

92-
93-
parts = authorization_header.split(" ")
109+
# Split authorization header on first whitespace
110+
parts = authorization_header.split(None, 1)
94111
if len(parts) != 2:
95-
if len(parts) < 2:
96-
raise self._prepare_error(MissingAuthorizationError())
97-
elif len(parts) > 2:
98-
raise self._prepare_error(
99-
InvalidAuthSchemeError("")
100-
)
112+
raise self._prepare_error(MissingAuthorizationError())
101113

102114
scheme, token = parts
115+
scheme = scheme.lower()
103116

104-
scheme = scheme.strip().lower()
117+
# Tokens should not contain spaces (indicates malformed header)
118+
if " " in token:
119+
raise self._prepare_error(InvalidAuthSchemeError(""))
105120

106121
if self.is_dpop_required() and scheme != "dpop":
107122
raise self._prepare_error(
@@ -501,6 +516,211 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict
501516
exc
502517
)
503518

519+
async def get_token_by_exchange_profile(
520+
self,
521+
subject_token: str,
522+
subject_token_type: str,
523+
audience: Optional[str] = None,
524+
scope: Optional[str] = None,
525+
requested_token_type: Optional[str] = None,
526+
extra: Optional[dict[str, Union[str, list[str]]]] = None
527+
) -> dict[str, Any]:
528+
"""
529+
Exchange a subject token for an Auth0 token using RFC 8693.
530+
531+
The matching Token Exchange Profile is selected by subject_token_type.
532+
This method requires a confidential client (client_id and client_secret must be configured).
533+
534+
Args:
535+
subject_token: The token to be exchanged
536+
subject_token_type: URI identifying the token type (must match a Token Exchange Profile)
537+
audience: Optional target API identifier for the exchanged tokens
538+
scope: Optional space-separated OAuth 2.0 scopes to request
539+
requested_token_type: Optional type of token to issue (defaults to access token)
540+
extra: Optional additional parameters sent as form fields to Auth0.
541+
Cannot override reserved OAuth parameters.
542+
543+
Returns:
544+
Dictionary containing:
545+
- access_token (str): The Auth0 access token
546+
- expires_in (int): Token lifetime in seconds
547+
- expires_at (int): Unix timestamp when token expires
548+
- id_token (str, optional): OpenID Connect ID token
549+
- refresh_token (str, optional): Refresh token
550+
- scope (str, optional): Granted scopes
551+
- token_type (str, optional): Token type (typically "Bearer")
552+
- issued_token_type (str, optional): RFC 8693 issued token type identifier
553+
554+
Raises:
555+
MissingRequiredArgumentError: If required parameters are missing
556+
GetTokenByExchangeProfileError: If client credentials not configured, validation fails,
557+
or reserved parameters are supplied in extra
558+
ApiError: If the token endpoint returns an error
559+
560+
Example:
561+
>>> result = await api_client.get_token_by_exchange_profile(
562+
... subject_token=token,
563+
... subject_token_type="urn:example:subject-token",
564+
... audience="https://api.backend.com"
565+
... )
566+
567+
References:
568+
- Custom Token Exchange: https://auth0.com/docs/authenticate/custom-token-exchange
569+
- RFC 8693: https://datatracker.ietf.org/doc/html/rfc8693
570+
- Related SDK: https://github.com/auth0/auth0-auth-js
571+
"""
572+
# Validate required parameters
573+
if not subject_token:
574+
raise MissingRequiredArgumentError("subject_token")
575+
if not subject_token_type:
576+
raise MissingRequiredArgumentError("subject_token_type")
577+
578+
# Validate subject token format (fail fast to ensure token integrity)
579+
if not isinstance(subject_token, str):
580+
raise GetTokenByExchangeProfileError("subject_token must be a string")
581+
582+
# Fail fast on blank or whitespace-only
583+
if not subject_token.strip():
584+
raise GetTokenByExchangeProfileError("subject_token cannot be blank or whitespace")
585+
586+
# Be explicit about surrounding spaces (prevents token ambiguity)
587+
if subject_token != subject_token.strip():
588+
raise GetTokenByExchangeProfileError(
589+
"subject_token must not include leading or trailing whitespace"
590+
)
591+
592+
# Very common copy-paste mistake (case-insensitive check)
593+
if subject_token.lower().startswith("bearer "):
594+
raise GetTokenByExchangeProfileError(
595+
"subject_token must not include the 'Bearer ' prefix"
596+
)
597+
598+
# Require client credentials
599+
client_id = self.options.client_id
600+
client_secret = self.options.client_secret
601+
if not client_id or not client_secret:
602+
raise GetTokenByExchangeProfileError(
603+
"Client credentials are required to use get_token_by_exchange_profile"
604+
)
605+
606+
# Discover token endpoint
607+
metadata = await self._discover()
608+
token_endpoint = metadata.get("token_endpoint")
609+
if not token_endpoint:
610+
raise GetTokenByExchangeProfileError("Token endpoint missing in OIDC metadata")
611+
612+
# Build request parameters (client_id sent via HTTP Basic auth only)
613+
params = {
614+
"grant_type": TOKEN_EXCHANGE_GRANT_TYPE,
615+
"subject_token": subject_token,
616+
"subject_token_type": subject_token_type,
617+
}
618+
619+
# Add optional parameters
620+
if audience:
621+
params["audience"] = audience
622+
if scope:
623+
params["scope"] = scope
624+
if requested_token_type:
625+
params["requested_token_type"] = requested_token_type
626+
627+
# Append extra parameters with validation
628+
if extra:
629+
for parameter_key, parameter_value in extra.items():
630+
# Fail fast if reserved parameter is supplied
631+
if parameter_key in RESERVED_PARAMS:
632+
raise GetTokenByExchangeProfileError(
633+
f"Parameter '{parameter_key}' is reserved and cannot be overridden"
634+
)
635+
636+
# Store value (httpx handles list encoding as multiple key=value pairs)
637+
if isinstance(parameter_value, list):
638+
# DoS protection: enforce array size limit
639+
if len(parameter_value) > MAX_ARRAY_VALUES_PER_KEY:
640+
raise GetTokenByExchangeProfileError(
641+
f"Parameter '{parameter_key}' exceeds maximum array size of {MAX_ARRAY_VALUES_PER_KEY}"
642+
)
643+
params[parameter_key] = parameter_value
644+
else:
645+
params[parameter_key] = str(parameter_value)
646+
647+
# Make token exchange request
648+
try:
649+
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
650+
response = await client.post(
651+
token_endpoint,
652+
data=params,
653+
auth=(client_id, client_secret)
654+
)
655+
656+
if response.status_code != 200:
657+
error_data = {}
658+
try:
659+
if "json" in response.headers.get("content-type", "").lower():
660+
error_data = response.json()
661+
except ValueError:
662+
pass # Ignore JSON parse errors, use generic error message below
663+
664+
raise ApiError(
665+
error_data.get("error", "token_exchange_error"),
666+
error_data.get(
667+
"error_description",
668+
f"Failed to exchange token of type '{subject_token_type}'"
669+
+ (f" for audience '{audience}'" if audience else "")
670+
),
671+
response.status_code
672+
)
673+
674+
try:
675+
token_response = response.json()
676+
except ValueError:
677+
raise ApiError("invalid_json", "Token endpoint returned invalid JSON.", 502)
678+
679+
# Validate required fields
680+
access_token = token_response.get("access_token")
681+
if not isinstance(access_token, str) or not access_token:
682+
raise ApiError(
683+
"invalid_response",
684+
"Missing or invalid access_token in response.",
685+
502
686+
)
687+
688+
expires_in_raw = token_response.get("expires_in", 3600)
689+
try:
690+
expires_in = int(expires_in_raw)
691+
except (TypeError, ValueError):
692+
raise ApiError("invalid_response", "expires_in is not an integer.", 502)
693+
694+
# Build response with required fields
695+
result = {
696+
"access_token": access_token,
697+
"expires_in": expires_in,
698+
"expires_at": int(time.time()) + expires_in,
699+
}
700+
701+
# Add optional fields if present
702+
optional_fields = ["scope", "id_token", "refresh_token", "token_type", "issued_token_type"]
703+
for field in optional_fields:
704+
if value := token_response.get(field):
705+
result[field] = value
706+
707+
return result
708+
709+
except httpx.TimeoutException as exc:
710+
raise ApiError(
711+
"timeout_error",
712+
f"Request to token endpoint timed out: {str(exc)}",
713+
504,
714+
exc
715+
)
716+
except httpx.HTTPError as exc:
717+
raise ApiError(
718+
"network_error",
719+
f"Network error occurred: {str(exc)}",
720+
502,
721+
exc
722+
)
723+
504724
# ===== Private Methods =====
505725

506726
async def _discover(self) -> dict[str, Any]:

0 commit comments

Comments
 (0)