Skip to content

Commit 19adbcf

Browse files
feat: validate CTE actor token pairing and surface act claim for delegation
1 parent 7a04fb8 commit 19adbcf

5 files changed

Lines changed: 276 additions & 50 deletions

File tree

examples/CustomTokenExchange.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ print(f"User logged in: {user['sub']}")
6464
6565
## 3. Actor Tokens (Delegation)
6666

67-
Enable delegation scenarios where one service acts on behalf of a user.
67+
Enable delegation scenarios where one party acts on behalf of a user. The acting party is supplied via `actor_token`, and Auth0 records it in the [`act` claim](https://datatracker.ietf.org/doc/html/rfc8693#section-4.1) on the issued tokens.
6868

6969
```python
7070
# Service acting on behalf of a user
@@ -77,8 +77,32 @@ response = await auth0.custom_token_exchange(
7777
audience="https://api.example.com"
7878
)
7979
)
80+
81+
# The actor claim is surfaced on the response. It may nest for delegation chains.
82+
if response.act:
83+
print(f"Acting party: {response.act['sub']}")
8084
```
8185

86+
When you establish a session with `login_with_custom_token_exchange()`, the `act` claim is persisted on the session user and can be read back later via `get_user()`:
87+
88+
```python
89+
result = await auth0.login_with_custom_token_exchange(
90+
LoginWithCustomTokenExchangeOptions(
91+
subject_token="user-access-token",
92+
subject_token_type="urn:ietf:params:oauth:token-type:access_token",
93+
actor_token="service-access-token",
94+
actor_token_type="urn:ietf:params:oauth:token-type:access_token",
95+
),
96+
store_options={"request": request, "response": response}
97+
)
98+
99+
user = result.state_data["user"]
100+
if user.get("act"):
101+
print(f"Acting party: {user['act']['sub']}")
102+
```
103+
104+
> **NOTE**: When an `actor_token` is present, Auth0 does not issue a refresh token (the `offline_access` scope is dropped). A subsequent refresh-token grant therefore cannot re-emit the `act` claim, so the acting party is fixed at exchange time.
105+
82106
## 4. Custom Authorization Parameters
83107

84108
Pass additional parameters to the token endpoint.
@@ -133,6 +157,7 @@ except CustomTokenExchangeError as e:
133157

134158
- `INVALID_TOKEN_FORMAT`: Token is empty, whitespace-only, or has "Bearer " prefix
135159
- `MISSING_ACTOR_TOKEN_TYPE`: `actor_token` provided without `actor_token_type`
160+
- `MISSING_ACTOR_TOKEN`: `actor_token_type` provided without `actor_token`
136161
- `TOKEN_EXCHANGE_FAILED`: General token exchange failure
137162
- `INVALID_RESPONSE`: Auth0 returned a non-JSON response
138163

src/auth0_server_python/auth_server/server_client.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2243,10 +2243,35 @@ async def custom_token_exchange(
22432243
https://datatracker.ietf.org/doc/html/rfc8693
22442244
"""
22452245
try:
2246-
# Validate options (Pydantic handles this automatically)
22472246
if not isinstance(options, CustomTokenExchangeOptions):
22482247
options = CustomTokenExchangeOptions(**options)
22492248

2249+
if not options.subject_token.strip():
2250+
raise CustomTokenExchangeError(
2251+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2252+
"subject_token cannot be empty or whitespace-only"
2253+
)
2254+
if options.subject_token.strip().startswith("Bearer "):
2255+
raise CustomTokenExchangeError(
2256+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2257+
"subject_token should not include 'Bearer ' prefix"
2258+
)
2259+
if options.actor_token and options.actor_token.strip().startswith("Bearer "):
2260+
raise CustomTokenExchangeError(
2261+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2262+
"actor_token should not include 'Bearer ' prefix"
2263+
)
2264+
if options.actor_token and not options.actor_token_type:
2265+
raise CustomTokenExchangeError(
2266+
CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN_TYPE,
2267+
"actor_token_type is required when actor_token is provided"
2268+
)
2269+
if options.actor_token_type and not options.actor_token:
2270+
raise CustomTokenExchangeError(
2271+
CustomTokenExchangeErrorCode.MISSING_ACTOR_TOKEN,
2272+
"actor_token is required when actor_token_type is provided"
2273+
)
2274+
22502275
# Resolve domain
22512276
domain = await self._resolve_current_domain(store_options)
22522277
metadata = await self._get_oidc_metadata_cached(domain)
@@ -2309,8 +2334,17 @@ async def custom_token_exchange(
23092334
"Failed to parse token response as JSON"
23102335
)
23112336

2312-
# Validate and return response
2313-
return TokenExchangeResponse(**token_data)
2337+
token_response = TokenExchangeResponse(**token_data)
2338+
2339+
# Surface the actor claim for delegation exchanges
2340+
if options.actor_token and token_response.id_token:
2341+
jwks = await self._get_jwks_cached(domain, metadata)
2342+
claims = await self._verify_and_decode_jwt(
2343+
token_response.id_token, jwks, audience=self._client_id
2344+
)
2345+
token_response.act = claims.get("act")
2346+
2347+
return token_response
23142348

23152349
except ValidationError as e:
23162350
raise CustomTokenExchangeError(

src/auth0_server_python/auth_types/__init__.py

Lines changed: 7 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from typing import Any, Literal, Optional, Union
77

8-
from pydantic import BaseModel, Field, field_validator, model_validator
8+
from pydantic import BaseModel, Field
99

1010

1111
class UserClaims(BaseModel):
@@ -257,33 +257,15 @@ class CustomTokenExchangeOptions(BaseModel):
257257
organization: Organization identifier for the token exchange (optional)
258258
authorization_params: Additional OAuth parameters (optional)
259259
"""
260-
subject_token: str = Field(..., min_length=1)
261-
subject_token_type: str = Field(..., min_length=1)
260+
subject_token: str
261+
subject_token_type: str
262262
audience: Optional[str] = None
263263
scope: Optional[str] = None
264264
actor_token: Optional[str] = None
265265
actor_token_type: Optional[str] = None
266266
organization: Optional[str] = None
267267
authorization_params: Optional[dict[str, Any]] = None
268268

269-
@field_validator('subject_token', 'actor_token')
270-
@classmethod
271-
def validate_token_format(cls, v: Optional[str]) -> Optional[str]:
272-
"""Validate token doesn't have Bearer prefix and isn't whitespace-only."""
273-
if v is not None:
274-
if not v.strip():
275-
raise ValueError("Token cannot be empty or whitespace-only")
276-
if v.strip().startswith("Bearer "):
277-
raise ValueError("Token should not include 'Bearer ' prefix")
278-
return v
279-
280-
@model_validator(mode='after')
281-
def validate_actor_token_type(self) -> 'CustomTokenExchangeOptions':
282-
"""Ensure actor_token_type is provided if actor_token is present."""
283-
if self.actor_token and not self.actor_token_type:
284-
raise ValueError("actor_token_type is required when actor_token is provided")
285-
return self
286-
287269

288270
class TokenExchangeResponse(BaseModel):
289271
"""
@@ -297,6 +279,7 @@ class TokenExchangeResponse(BaseModel):
297279
issued_token_type: Format of issued token
298280
id_token: OpenID Connect ID token (optional)
299281
refresh_token: Refresh token (optional)
282+
act: Actor claim for delegation/impersonation exchanges (optional)
300283
"""
301284
access_token: str
302285
token_type: str = "Bearer"
@@ -305,6 +288,7 @@ class TokenExchangeResponse(BaseModel):
305288
issued_token_type: Optional[str] = None
306289
id_token: Optional[str] = None
307290
refresh_token: Optional[str] = None
291+
act: Optional[dict[str, Any]] = None
308292

309293

310294
class LoginWithCustomTokenExchangeOptions(BaseModel):
@@ -313,33 +297,15 @@ class LoginWithCustomTokenExchangeOptions(BaseModel):
313297
314298
Combines token exchange parameters with session management.
315299
"""
316-
subject_token: str = Field(..., min_length=1)
317-
subject_token_type: str = Field(..., min_length=1)
300+
subject_token: str
301+
subject_token_type: str
318302
audience: Optional[str] = None
319303
scope: Optional[str] = None
320304
actor_token: Optional[str] = None
321305
actor_token_type: Optional[str] = None
322306
organization: Optional[str] = None
323307
authorization_params: Optional[dict[str, Any]] = None
324308

325-
@field_validator('subject_token', 'actor_token')
326-
@classmethod
327-
def validate_token_format(cls, v: Optional[str]) -> Optional[str]:
328-
"""Validate token doesn't have Bearer prefix and isn't whitespace-only."""
329-
if v is not None:
330-
if not v.strip():
331-
raise ValueError("Token cannot be empty or whitespace-only")
332-
if v.strip().startswith("Bearer "):
333-
raise ValueError("Token should not include 'Bearer ' prefix")
334-
return v
335-
336-
@model_validator(mode='after')
337-
def validate_actor_token_type(self) -> 'LoginWithCustomTokenExchangeOptions':
338-
"""Ensure actor_token_type is provided if actor_token is present."""
339-
if self.actor_token and not self.actor_token_type:
340-
raise ValueError("actor_token_type is required when actor_token is provided")
341-
return self
342-
343309

344310
class LoginWithCustomTokenExchangeResult(BaseModel):
345311
"""

src/auth0_server_python/error/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,7 @@ class CustomTokenExchangeErrorCode:
225225
"""Error codes for custom token exchange operations."""
226226
INVALID_TOKEN_FORMAT = "invalid_token_format"
227227
MISSING_ACTOR_TOKEN_TYPE = "missing_actor_token_type"
228+
MISSING_ACTOR_TOKEN = "missing_actor_token"
228229
TOKEN_EXCHANGE_FAILED = "token_exchange_failed"
229230
INVALID_RESPONSE = "invalid_response"
230231

0 commit comments

Comments
 (0)