Skip to content

Commit 35f4fb7

Browse files
committed
update: add 'act' claim helpers
1 parent 56e06b1 commit 35f4fb7

8 files changed

Lines changed: 403 additions & 13 deletions

File tree

EXAMPLES.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ async def exchange_on_behalf_of():
3838
async with httpx.AsyncClient() as client:
3939
downstream_response = await client.get(
4040
"https://calendar-api.example.com/events",
41-
headers={"Authorization": f"Bearer {result.access_token}"}
41+
headers={"Authorization": f"Bearer {result['access_token']}"}
4242
)
4343

4444
downstream_response.raise_for_status()
@@ -60,6 +60,63 @@ asyncio.run(exchange_on_behalf_of())
6060
In the current implementation, `get_token_on_behalf_of()` forwards the incoming access token as
6161
the [RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693#section-2.1) `subject_token` and relies on Auth0 to handle any DPoP-specific behavior for that token.
6262

63+
## Inspecting Delegation After Token Verification
64+
65+
When a downstream API or `MCP` server receives an access token that may have been issued through
66+
delegation, it can verify the token first and then inspect the `act` claim to identify the current
67+
actor for authorization and the full delegation chain for audit or attribution.
68+
69+
```python
70+
import asyncio
71+
import logging
72+
73+
from auth0_api_python import (
74+
ApiClient,
75+
ApiClientOptions,
76+
get_current_actor,
77+
get_delegation_chain,
78+
)
79+
80+
logger = logging.getLogger(__name__)
81+
82+
async def inspect_delegated_token():
83+
api_client = ApiClient(ApiClientOptions(
84+
domain="your-tenant.auth0.com",
85+
audience="https://calendar-api.example.com"
86+
))
87+
88+
access_token = "delegated-auth0-access-token"
89+
90+
claims = await api_client.verify_access_token(access_token=access_token)
91+
92+
current_actor = get_current_actor(claims)
93+
delegation_chain = get_delegation_chain(claims)
94+
95+
if current_actor != "mcp_server_client_id":
96+
raise PermissionError("unexpected actor")
97+
98+
logger.info(
99+
"delegated request",
100+
extra={
101+
"user_sub": claims["sub"],
102+
"current_actor": current_actor,
103+
"delegation_chain": delegation_chain,
104+
},
105+
)
106+
107+
return {
108+
"user_sub": claims["sub"],
109+
"current_actor": current_actor,
110+
"delegation_chain": delegation_chain,
111+
}
112+
113+
asyncio.run(inspect_delegated_token())
114+
```
115+
116+
Only the outermost `act.sub` represents the current actor and should be used for authorization
117+
decisions. Nested `act` values represent prior actors and are better suited for logging, audit, or
118+
attribution.
119+
63120
## Bearer Authentication
64121

65122
Bearer authentication is the standard OAuth 2.0 token authentication method.

README.md

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ async def handle_calendar_request(incoming_access_token: str):
236236
async with httpx.AsyncClient() as client:
237237
downstream_response = await client.get(
238238
"https://calendar-api.example.com/events",
239-
headers={"Authorization": f"Bearer {result.access_token}"}
239+
headers={"Authorization": f"Bearer {result['access_token']}"}
240240
)
241241

242242
downstream_response.raise_for_status()
@@ -250,6 +250,54 @@ token as the `subject_token` and relies on Auth0 to handle any DPoP-specific beh
250250
The OBO result only includes access-token-oriented fields. It does not expose `id_token` or
251251
`refresh_token`.
252252

253+
#### Inspecting Delegation After Token Verification
254+
255+
When a downstream API or `MCP` server receives an access token that may have been issued through
256+
delegation, it can verify the token first and then inspect the `act` claim to identify the current
257+
actor for authorization and the full delegation chain for logging or audit.
258+
259+
```python
260+
import logging
261+
262+
from auth0_api_python import (
263+
ApiClient,
264+
ApiClientOptions,
265+
get_current_actor,
266+
get_delegation_chain,
267+
)
268+
269+
logger = logging.getLogger(__name__)
270+
271+
api_client = ApiClient(ApiClientOptions(
272+
domain="<AUTH0_DOMAIN>",
273+
audience="<AUTH0_AUDIENCE>",
274+
))
275+
276+
async def authorize_delegated_request(access_token: str):
277+
claims = await api_client.verify_access_token(access_token=access_token)
278+
279+
current_actor = get_current_actor(claims)
280+
delegation_chain = get_delegation_chain(claims)
281+
282+
if current_actor != "mcp_server_client_id":
283+
raise PermissionError("unexpected actor")
284+
285+
logger.info(
286+
"delegated request",
287+
extra={
288+
"user_sub": claims["sub"],
289+
"current_actor": current_actor,
290+
"delegation_chain": delegation_chain,
291+
},
292+
)
293+
294+
return claims
295+
```
296+
297+
Only the outermost `act.sub` represents the current actor and should be used for authorization
298+
decisions. Nested `act` values represent prior actors in the delegation chain and are better suited
299+
for logging, audit, or attribution.
300+
253301
#### Requiring Additional Claims
254302

255303
If your application demands extra claims, specify them with `required_claims`:

src/auth0_api_python/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
in server-side APIs, using Authlib for OIDC discovery and JWKS fetching.
66
"""
77

8+
from .act import get_current_actor, get_delegation_chain
89
from .api_client import ApiClient
910
from .cache import CacheAdapter, InMemoryCache
1011
from .config import ApiClientOptions
@@ -14,7 +15,11 @@
1415
DomainsResolverError,
1516
GetTokenByExchangeProfileError,
1617
)
17-
from .types import DomainsResolver, DomainsResolverContext, OnBehalfOfTokenResult
18+
from .types import (
19+
DomainsResolver,
20+
DomainsResolverContext,
21+
OnBehalfOfTokenResult,
22+
)
1823

1924
__all__ = [
2025
"ApiClient",
@@ -26,6 +31,8 @@
2631
"DomainsResolverContext",
2732
"DomainsResolverError",
2833
"GetTokenByExchangeProfileError",
34+
"get_current_actor",
35+
"get_delegation_chain",
2936
"InMemoryCache",
3037
"OnBehalfOfTokenResult",
3138
]

src/auth0_api_python/act.py

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""
2+
Helpers for working with the `act` claim on verified access token claims.
3+
"""
4+
5+
from __future__ import annotations
6+
7+
from collections.abc import Mapping
8+
from typing import Any
9+
10+
from .errors import VerifyAccessTokenError
11+
from .types import ActClaim
12+
13+
14+
def get_current_actor(claims: Mapping[str, Any]) -> str | None:
15+
"""
16+
Return the current actor from the outermost `act.sub`, if present.
17+
18+
Only the outermost `act.sub` should be used for authorization decisions.
19+
Nested `act` values represent prior actors and are informational.
20+
"""
21+
22+
act_claim = _get_validated_act_claim(claims)
23+
if act_claim is None:
24+
return None
25+
26+
return act_claim["sub"]
27+
28+
29+
def get_delegation_chain(claims: Mapping[str, Any]) -> list[str]:
30+
"""
31+
Return the delegation chain from newest actor to oldest actor.
32+
33+
The first entry is the current actor (outermost `act.sub`). Later entries are
34+
prior actors from nested `act` values and are typically most useful for audit
35+
and attribution.
36+
"""
37+
38+
act_claim = _get_validated_act_claim(claims)
39+
if act_claim is None:
40+
return []
41+
42+
chain: list[str] = []
43+
current: ActClaim | None = act_claim
44+
while current is not None:
45+
chain.append(current["sub"])
46+
current = current.get("act")
47+
48+
return chain
49+
50+
51+
def _get_validated_act_claim(claims: Mapping[str, Any]) -> ActClaim | None:
52+
if not isinstance(claims, Mapping):
53+
raise VerifyAccessTokenError("Verified access token claims must be an object")
54+
55+
act_claim = claims.get("act")
56+
if act_claim is None:
57+
return None
58+
59+
return _parse_act_claim(act_claim, path="act", seen=set())
60+
61+
62+
def _parse_act_claim(value: Any, *, path: str, seen: set[int]) -> ActClaim:
63+
if not isinstance(value, Mapping):
64+
raise VerifyAccessTokenError(f"{path} must be an object")
65+
66+
object_id = id(value)
67+
if object_id in seen:
68+
raise VerifyAccessTokenError(f"{path} contains a circular reference")
69+
70+
seen.add(object_id)
71+
try:
72+
sub = value.get("sub")
73+
if not isinstance(sub, str) or not sub.strip():
74+
raise VerifyAccessTokenError(f"{path}.sub must be a non-empty string")
75+
76+
parsed: ActClaim = {"sub": sub}
77+
nested_act = value.get("act")
78+
if nested_act is not None:
79+
parsed["act"] = _parse_act_claim(
80+
nested_act,
81+
path=f"{path}.act",
82+
seen=seen,
83+
)
84+
85+
return parsed
86+
finally:
87+
seen.remove(object_id)

src/auth0_api_python/api_client.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ async def verify_request(
234234
http_url: The HTTP URL (required for DPoP, also used for MCD resolver context)
235235
236236
Returns:
237-
The decoded access token claims
237+
The decoded access token claims, including `act` when present.
238238
239239
Raises:
240240
MissingRequiredArgumentError: If required args are missing
@@ -414,7 +414,7 @@ async def verify_access_token(
414414
required_claims: Optional list of additional claim names that must be present
415415
416416
Returns:
417-
The decoded token claims if valid.
417+
The decoded token claims if valid, including `act` when present.
418418
419419
Raises:
420420
MissingRequiredArgumentError: If no token is provided.
@@ -796,7 +796,7 @@ async def get_token_by_exchange_profile(
796796
Dictionary containing:
797797
- access_token (str): The Auth0 access token
798798
- expires_in (int): Token lifetime in seconds
799-
- expires_at (int): Unix timestamp when token expires
799+
- expires_at (int): Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in
800800
- id_token (str, optional): OpenID Connect ID token
801801
- refresh_token (str, optional): Refresh token
802802
- scope (str, optional): Granted scopes
@@ -986,7 +986,7 @@ async def get_token_on_behalf_of(
986986
Dictionary containing:
987987
- access_token (str): The exchanged Auth0 access token
988988
- expires_in (int): Token lifetime in seconds
989-
- expires_at (int): Unix timestamp when token expires
989+
- expires_at (int): Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in
990990
- scope (str, optional): Granted scopes
991991
- token_type (str, optional): Token type (typically "Bearer")
992992
- issued_token_type (str, optional): RFC 8693 issued token type identifier

src/auth0_api_python/types.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,23 @@
22
Type definitions for auth0-api-python SDK
33
"""
44

5+
from __future__ import annotations
6+
57
from collections.abc import Awaitable, Callable
6-
from typing import Optional, TypedDict, Union
8+
from typing import TypedDict
9+
10+
11+
class ActClaim(TypedDict, total=False):
12+
"""
13+
Actor claim carried by access tokens issued through delegation.
14+
15+
Attributes:
16+
sub: The current actor for this step of the delegation chain.
17+
act: The prior actor in the delegation chain, if present.
18+
"""
19+
20+
sub: str
21+
act: ActClaim
722

823

924
class DomainsResolverContext(TypedDict, total=False):
@@ -15,8 +30,8 @@ class DomainsResolverContext(TypedDict, total=False):
1530
request_headers: Request headers dict (e.g., Host, X-Forwarded-Host) (optional)
1631
unverified_iss: The issuer claim from the unverified token
1732
"""
18-
request_url: Optional[str]
19-
request_headers: Optional[dict]
33+
request_url: str | None
34+
request_headers: dict | None
2035
unverified_iss: str
2136

2237

@@ -27,7 +42,7 @@ class OnBehalfOfTokenResult(TypedDict, total=False):
2742
Attributes:
2843
access_token: The access token issued for the downstream API.
2944
expires_in: Token lifetime in seconds.
30-
expires_at: Unix timestamp when the token expires.
45+
expires_at: Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in.
3146
scope: Granted scopes, if returned by Auth0.
3247
token_type: Token type, if returned by Auth0.
3348
issued_token_type: RFC 8693 issued token type, if returned by Auth0.
@@ -41,7 +56,7 @@ class OnBehalfOfTokenResult(TypedDict, total=False):
4156
issued_token_type: str
4257

4358
DomainsResolver = Callable[
44-
[DomainsResolverContext], Union[list[str], Awaitable[list[str]]]
59+
[DomainsResolverContext], list[str] | Awaitable[list[str]]
4560
]
4661
"""
4762
Type alias for domains resolver function.

0 commit comments

Comments
 (0)