Skip to content

Commit 2feb760

Browse files
fix(cte): validate subject_token_type and actor_token, clarify act sourcing
Add local empty/whitespace guards for subject_token_type and actor_token so they fail with a clear INVALID_TOKEN_FORMAT instead of a generic round-trip error from the token endpoint, matching the existing subject_token check. Add tests for both cases. Document that response.act is read from the id_token and that Auth0 writes the same act onto the access token (verified against the platform token dialects), noting the access token may be opaque. Add a comment on the login path noting act reaches the session user via UserClaims. Rename the refresh test to reflect that it pins the state-merge behavior the refresh path uses.
1 parent ee6bcdc commit 2feb760

3 files changed

Lines changed: 68 additions & 2 deletions

File tree

examples/CustomTokenExchange.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ if response.act:
8383
print(f"Acting party: {response.act['sub']}")
8484
```
8585

86+
> **NOTE**: `response.act` is read from the ID token. Auth0 writes the same `act` claim onto the issued access token as well, so they reflect the same acting party. The access token may be opaque, in which case `act` cannot be read off it directly - the ID token is where you read it.
87+
8688
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()`:
8789

8890
```python

src/auth0_server_python/auth_server/server_client.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2251,11 +2251,21 @@ async def custom_token_exchange(
22512251
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
22522252
"subject_token cannot be empty or whitespace-only"
22532253
)
2254+
if not options.subject_token_type.strip():
2255+
raise CustomTokenExchangeError(
2256+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2257+
"subject_token_type cannot be empty or whitespace-only"
2258+
)
22542259
if options.subject_token.strip().startswith("Bearer "):
22552260
raise CustomTokenExchangeError(
22562261
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
22572262
"subject_token should not include 'Bearer ' prefix"
22582263
)
2264+
if options.actor_token is not None and not options.actor_token.strip():
2265+
raise CustomTokenExchangeError(
2266+
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
2267+
"actor_token cannot be empty or whitespace-only"
2268+
)
22592269
if options.actor_token and options.actor_token.strip().startswith("Bearer "):
22602270
raise CustomTokenExchangeError(
22612271
CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT,
@@ -2444,6 +2454,8 @@ async def login_with_custom_token_exchange(
24442454
"ID token issuer mismatch. Ensure your Auth0 domain is configured correctly."
24452455
)
24462456

2457+
# UserClaims allows extra fields, so any act claim in the
2458+
# verified id_token is carried onto the session user here.
24472459
user_claims = UserClaims.parse_obj(claims)
24482460
# Extract sid from token if available
24492461
sid = claims.get("sid", sid)

src/auth0_server_python/tests/test_server_client.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3141,6 +3141,58 @@ async def test_custom_token_exchange_missing_actor_token():
31413141
assert "actor_token" in str(exc.value).lower()
31423142

31433143

3144+
@pytest.mark.asyncio
3145+
async def test_custom_token_exchange_empty_subject_token_type():
3146+
"""Test that empty/whitespace subject_token_type is rejected locally."""
3147+
# Setup
3148+
client = ServerClient(
3149+
domain="auth0.local",
3150+
client_id="<client_id>",
3151+
client_secret="<client_secret>",
3152+
state_store=AsyncMock(),
3153+
transaction_store=AsyncMock(),
3154+
secret="some-secret"
3155+
)
3156+
3157+
# Act & Assert
3158+
with pytest.raises(CustomTokenExchangeError) as exc:
3159+
await client.custom_token_exchange(
3160+
CustomTokenExchangeOptions(
3161+
subject_token="token",
3162+
subject_token_type=" "
3163+
)
3164+
)
3165+
assert exc.value.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT
3166+
assert "subject_token_type" in str(exc.value).lower()
3167+
3168+
3169+
@pytest.mark.asyncio
3170+
async def test_custom_token_exchange_whitespace_actor_token():
3171+
"""Test that a whitespace-only actor_token is rejected locally."""
3172+
# Setup
3173+
client = ServerClient(
3174+
domain="auth0.local",
3175+
client_id="<client_id>",
3176+
client_secret="<client_secret>",
3177+
state_store=AsyncMock(),
3178+
transaction_store=AsyncMock(),
3179+
secret="some-secret"
3180+
)
3181+
3182+
# Act & Assert
3183+
with pytest.raises(CustomTokenExchangeError) as exc:
3184+
await client.custom_token_exchange(
3185+
CustomTokenExchangeOptions(
3186+
subject_token="token",
3187+
subject_token_type="urn:acme:token",
3188+
actor_token=" ",
3189+
actor_token_type="urn:ietf:params:oauth:token-type:access_token"
3190+
)
3191+
)
3192+
assert exc.value.code == CustomTokenExchangeErrorCode.INVALID_TOKEN_FORMAT
3193+
assert "actor_token" in str(exc.value).lower()
3194+
3195+
31443196
@pytest.mark.asyncio
31453197
async def test_custom_token_exchange_api_error_400(mocker):
31463198
"""Test handling of 400 error from Auth0."""
@@ -3829,8 +3881,8 @@ async def test_login_with_custom_token_exchange_persists_act_on_user(mocker):
38293881
assert result.state_data["user"]["act"] == {"sub": "agent|abc", "act": {"sub": "svc|xyz"}}
38303882

38313883

3832-
def test_act_claim_survives_token_refresh():
3833-
"""A refresh-token grant must not drop the login-time act claim from the user."""
3884+
def test_state_merge_preserves_user_act_claim():
3885+
"""The state merge used on refresh must not drop the user's act claim."""
38343886
state_data = {
38353887
"user": {"sub": "user123", "act": {"sub": "agent|abc"}},
38363888
"id_token": "old.jwt",

0 commit comments

Comments
 (0)