Skip to content

Commit 337a445

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 automatic whitespace normalization - Support for optional audience, scope, and requested_token_type parameters - Extra parameters for Actions with reserved param validation (fail-fast) - Returns access_token, expires_in, expires_at, and optional fields - HTTP Basic authentication with confidential client credentials - Comprehensive error handling with upstream status preservation Implementation details: - Validates subject tokens (strips whitespace, rejects Bearer prefix) - Fails fast when reserved OAuth parameters supplied in extras - Preserves upstream 4xx error status codes - Adds GetTokenByExchangeProfileError for validation failures 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 Tests: 24 new tests covering success paths, validation, error handling, and edge cases. All 84 tests passing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a4c600c commit 337a445

5 files changed

Lines changed: 566 additions & 3 deletions

File tree

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: 207 additions & 1 deletion
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,
@@ -501,6 +502,211 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict
501502
exc
502503
)
503504

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

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

src/auth0_api_python/errors.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,16 @@ def get_error_code(self) -> str:
106106
return "get_access_token_for_connection_error"
107107

108108

109+
class GetTokenByExchangeProfileError(BaseAuthError):
110+
"""Error raised when getting a token via exchange profile fails."""
111+
112+
def get_status_code(self) -> int:
113+
return 400
114+
115+
def get_error_code(self) -> str:
116+
return "get_token_by_exchange_profile_error"
117+
118+
109119
class ApiError(BaseAuthError):
110120
"""
111121
Error raised when an API request to Auth0 fails.

0 commit comments

Comments
 (0)