Skip to content

Commit 49e5944

Browse files
committed
poc: add get_token_on_behalf_of() to auth0-api-python
1 parent cca3b25 commit 49e5944

9 files changed

Lines changed: 700 additions & 10 deletions

File tree

EXAMPLES.md

Lines changed: 116 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,121 @@
22

33
This document provides examples for using the `auth0-api-python` package to validate Auth0 tokens in your API.
44

5+
## On Behalf Of Token Exchange
6+
7+
Use `get_token_on_behalf_of()` when your API receives an `Auth0` access token for itself and needs
8+
to exchange it for another `Auth0` access token targeting a downstream API while preserving the same
9+
user identity. This is especially useful for `MCP` servers and other intermediary APIs that need to
10+
call downstream APIs on behalf of the user.
11+
12+
The following example verifies the incoming access token for your API, exchanges it for a token for the downstream API, and then calls the downstream API with the exchanged token.
13+
14+
```python
15+
import asyncio
16+
import httpx
17+
18+
from auth0_api_python import ApiClient, ApiClientOptions
19+
20+
async def exchange_on_behalf_of():
21+
api_client = ApiClient(ApiClientOptions(
22+
domain="your-tenant.auth0.com",
23+
audience="https://mcp-server.example.com",
24+
client_id="<AUTH0_CLIENT_ID>",
25+
client_secret="<AUTH0_CLIENT_SECRET>"
26+
))
27+
28+
incoming_access_token = "incoming-auth0-access-token"
29+
30+
claims = await api_client.verify_access_token(access_token=incoming_access_token)
31+
32+
result = await api_client.get_token_on_behalf_of(
33+
access_token=incoming_access_token,
34+
audience="https://calendar-api.example.com",
35+
scope="calendar:read calendar:write"
36+
)
37+
38+
async with httpx.AsyncClient() as client:
39+
downstream_response = await client.get(
40+
"https://calendar-api.example.com/events",
41+
headers={"Authorization": f"Bearer {result['access_token']}"}
42+
)
43+
44+
downstream_response.raise_for_status()
45+
46+
return {
47+
"user": claims["sub"],
48+
"data": downstream_response.json(),
49+
}
50+
51+
asyncio.run(exchange_on_behalf_of())
52+
```
53+
54+
> [!TIP] Production notes:
55+
> - Pass the raw access token to `get_token_on_behalf_of()`. Do not pass the full `Authorization` header or include the `Bearer ` prefix.
56+
> - Verify the incoming token for your API before exchanging it so your application rejects invalid or mis-targeted tokens early.
57+
> - The downstream `audience` must match an API identifier configured in your Auth0 tenant.
58+
> - `get_token_on_behalf_of()` only returns access-token-oriented fields. It does not expose `id_token` or `refresh_token`.
59+
60+
In the current implementation, `get_token_on_behalf_of()` forwards the incoming access token as
61+
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.
62+
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+
5120
## Bearer Authentication
6121

7122
Bearer authentication is the standard OAuth 2.0 token authentication method.
@@ -157,4 +272,4 @@ async def verify_dpop_token(access_token, dpop_proof, http_method, http_url):
157272
"token_claims": token_claims,
158273
"proof_claims": proof_claims
159274
}
160-
```
275+
```

README.md

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,92 @@ except ApiError as e:
212212

213213
More info: https://auth0.com/docs/authenticate/custom-token-exchange
214214

215+
#### On Behalf Of Token Exchange
216+
217+
Use `get_token_on_behalf_of()` when your API receives an `Auth0` access token for itself and needs
218+
to exchange it for another `Auth0` access token targeting a downstream API while preserving the
219+
same user identity. This is especially useful for `MCP` servers and other intermediary APIs that
220+
need to call downstream APIs on behalf of the user.
221+
222+
The following example verifies the incoming access token for your API, exchanges it for a token for the downstream API, and then calls the downstream API with the exchanged token.
223+
224+
```python
225+
import httpx
226+
227+
async def handle_calendar_request(incoming_access_token: str):
228+
await api_client.verify_access_token(access_token=incoming_access_token)
229+
230+
result = await api_client.get_token_on_behalf_of(
231+
access_token=incoming_access_token,
232+
audience="https://calendar-api.example.com",
233+
scope="calendar:read calendar:write"
234+
)
235+
236+
async with httpx.AsyncClient() as client:
237+
downstream_response = await client.get(
238+
"https://calendar-api.example.com/events",
239+
headers={"Authorization": f"Bearer {result['access_token']}"}
240+
)
241+
242+
downstream_response.raise_for_status()
243+
244+
return downstream_response.json()
245+
```
246+
247+
The OBO wrapper reuses the existing RFC 8693 exchange support and fixes both token-type parameters
248+
to Auth0 access-token exchange. In the current implementation, the SDK forwards the incoming access
249+
token as the `subject_token` and relies on Auth0 to handle any DPoP-specific behavior for that token.
250+
The OBO result only includes access-token-oriented fields. It does not expose `id_token` or
251+
`refresh_token`.
252+
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+
215301
#### Requiring Additional Claims
216302

217303
If your application demands extra claims, specify them with `required_claims`:
@@ -353,4 +439,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
353439
</p>
354440
<p align="center">
355441
This project is licensed under the MIT license. See the <a href="https://github.com/auth0/auth0-api-python/LICENSE"> LICENSE</a> file for more info.
356-
</p>
442+
</p>

src/auth0_api_python/__init__.py

Lines changed: 9 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
18+
from .types import (
19+
DomainsResolver,
20+
DomainsResolverContext,
21+
OnBehalfOfTokenResult,
22+
)
1823

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

src/auth0_api_python/act.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""
2+
Helpers for working with the `act` claim on verified access token claims.
3+
"""
4+
5+
from collections.abc import Mapping
6+
from typing import Any, Optional
7+
8+
from .errors import VerifyAccessTokenError
9+
10+
INVALID_ACT_CLAIM_MESSAGE = "Invalid act claim"
11+
12+
13+
def get_current_actor(claims: Mapping[str, Any]) -> Optional[str]:
14+
"""
15+
Return the current actor from the outermost `act.sub`, if present.
16+
17+
Only the outermost `act.sub` should be used for authorization decisions.
18+
Nested `act` values represent prior actors and are informational.
19+
"""
20+
if not isinstance(claims, Mapping):
21+
raise VerifyAccessTokenError(INVALID_ACT_CLAIM_MESSAGE)
22+
23+
act_claim = claims.get("act")
24+
if act_claim is None:
25+
return None
26+
27+
if not isinstance(act_claim, Mapping):
28+
raise VerifyAccessTokenError(INVALID_ACT_CLAIM_MESSAGE)
29+
30+
sub = act_claim.get("sub")
31+
if not isinstance(sub, str) or not sub.strip():
32+
raise VerifyAccessTokenError(INVALID_ACT_CLAIM_MESSAGE)
33+
34+
return sub
35+
36+
37+
def get_delegation_chain(claims: Mapping[str, Any]) -> list[str]:
38+
"""
39+
Return the delegation chain from newest actor to oldest actor.
40+
41+
The first entry is the current actor (outermost `act.sub`). Later entries are
42+
prior actors from nested `act` values and are typically most useful for audit
43+
and attribution.
44+
"""
45+
if not isinstance(claims, Mapping):
46+
raise VerifyAccessTokenError(INVALID_ACT_CLAIM_MESSAGE)
47+
48+
current = claims.get("act")
49+
if current is None:
50+
return []
51+
52+
chain: list[str] = []
53+
while current is not None:
54+
if not isinstance(current, Mapping):
55+
raise VerifyAccessTokenError(INVALID_ACT_CLAIM_MESSAGE)
56+
57+
sub = current.get("sub")
58+
if not isinstance(sub, str) or not sub.strip():
59+
raise VerifyAccessTokenError(INVALID_ACT_CLAIM_MESSAGE)
60+
61+
chain.append(sub)
62+
current = current.get("act")
63+
64+
return chain

0 commit comments

Comments
 (0)