auth0-api-python 1.0.0b8__tar.gz → 1.0.0b9__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth0-api-python
3
- Version: 1.0.0b8
3
+ Version: 1.0.0b9
4
4
  Summary: SDK for verifying access tokens and securing APIs with Auth0, using Authlib.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -236,6 +236,92 @@ except ApiError as e:
236
236
 
237
237
  More info: https://auth0.com/docs/authenticate/custom-token-exchange
238
238
 
239
+ #### On Behalf Of Token Exchange
240
+
241
+ Use `get_token_on_behalf_of()` when your API receives an `Auth0` access token for itself and needs
242
+ to exchange it for another `Auth0` access token targeting a downstream API while preserving the
243
+ same user identity. This is especially useful for `MCP` servers and other intermediary APIs that
244
+ need to call downstream APIs on behalf of the user.
245
+
246
+ 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.
247
+
248
+ ```python
249
+ import httpx
250
+
251
+ async def handle_calendar_request(incoming_access_token: str):
252
+ await api_client.verify_access_token(access_token=incoming_access_token)
253
+
254
+ result = await api_client.get_token_on_behalf_of(
255
+ access_token=incoming_access_token,
256
+ audience="https://calendar-api.example.com",
257
+ scope="calendar:read calendar:write"
258
+ )
259
+
260
+ async with httpx.AsyncClient() as client:
261
+ downstream_response = await client.get(
262
+ "https://calendar-api.example.com/events",
263
+ headers={"Authorization": f"Bearer {result['access_token']}"}
264
+ )
265
+
266
+ downstream_response.raise_for_status()
267
+
268
+ return downstream_response.json()
269
+ ```
270
+
271
+ The OBO wrapper reuses the existing RFC 8693 exchange support and fixes both token-type parameters
272
+ to Auth0 access-token exchange. In the current implementation, the SDK forwards the incoming access
273
+ token as the `subject_token` and relies on Auth0 to handle any DPoP-specific behavior for that token.
274
+ The OBO result only includes access-token-oriented fields. It does not expose `id_token` or
275
+ `refresh_token`.
276
+
277
+ #### Inspecting Delegation After Token Verification
278
+
279
+ When a downstream API or `MCP` server receives an access token that may have been issued through
280
+ delegation, it can verify the token first and then inspect the `act` claim to identify the current
281
+ actor for authorization and the full delegation chain for logging or audit.
282
+
283
+ ```python
284
+ import logging
285
+
286
+ from auth0_api_python import (
287
+ ApiClient,
288
+ ApiClientOptions,
289
+ get_current_actor,
290
+ get_delegation_chain,
291
+ )
292
+
293
+ logger = logging.getLogger(__name__)
294
+
295
+ api_client = ApiClient(ApiClientOptions(
296
+ domain="<AUTH0_DOMAIN>",
297
+ audience="<AUTH0_AUDIENCE>",
298
+ ))
299
+
300
+ async def authorize_delegated_request(access_token: str):
301
+ claims = await api_client.verify_access_token(access_token=access_token)
302
+
303
+ current_actor = get_current_actor(claims)
304
+ delegation_chain = get_delegation_chain(claims)
305
+
306
+ if current_actor != "mcp_server_client_id":
307
+ raise PermissionError("unexpected actor")
308
+
309
+ logger.info(
310
+ "delegated request",
311
+ extra={
312
+ "user_sub": claims["sub"],
313
+ "current_actor": current_actor,
314
+ "delegation_chain": delegation_chain,
315
+ },
316
+ )
317
+
318
+ return claims
319
+ ```
320
+
321
+ Only the outermost `act.sub` represents the current actor and should be used for authorization
322
+ decisions. Nested `act` values represent prior actors in the delegation chain and are better suited
323
+ for logging, audit, or attribution.
324
+
239
325
  #### Requiring Additional Claims
240
326
 
241
327
  If your application demands extra claims, specify them with `required_claims`:
@@ -378,3 +464,4 @@ Please do not report security vulnerabilities on the public GitHub issue tracker
378
464
  <p align="center">
379
465
  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.
380
466
  </p>
467
+
@@ -212,6 +212,92 @@ except ApiError as e:
212
212
 
213
213
  More info: https://auth0.com/docs/authenticate/custom-token-exchange
214
214
 
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
+
215
301
  #### Requiring Additional Claims
216
302
 
217
303
  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
353
439
  </p>
354
440
  <p align="center">
355
441
  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>
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-api-python"
3
- version = "1.0.0b8"
3
+ version = "1.0.0b9"
4
4
  description = "SDK for verifying access tokens and securing APIs with Auth0, using Authlib."
5
5
  authors = ["Auth0 <support@auth0.com>"]
6
6
  license = "MIT"
@@ -5,6 +5,7 @@ A lightweight Python SDK for verifying Auth0-issued access tokens
5
5
  in server-side APIs, using Authlib for OIDC discovery and JWKS fetching.
6
6
  """
7
7
 
8
+ from .act import get_current_actor, get_delegation_chain
8
9
  from .api_client import ApiClient
9
10
  from .cache import CacheAdapter, InMemoryCache
10
11
  from .config import ApiClientOptions
@@ -14,7 +15,11 @@ from .errors import (
14
15
  DomainsResolverError,
15
16
  GetTokenByExchangeProfileError,
16
17
  )
17
- from .types import DomainsResolver, DomainsResolverContext
18
+ from .types import (
19
+ DomainsResolver,
20
+ DomainsResolverContext,
21
+ OnBehalfOfTokenResult,
22
+ )
18
23
 
19
24
  __all__ = [
20
25
  "ApiClient",
@@ -26,5 +31,8 @@ __all__ = [
26
31
  "DomainsResolverContext",
27
32
  "DomainsResolverError",
28
33
  "GetTokenByExchangeProfileError",
34
+ "get_current_actor",
35
+ "get_delegation_chain",
29
36
  "InMemoryCache",
37
+ "OnBehalfOfTokenResult",
30
38
  ]
@@ -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
@@ -21,6 +21,7 @@ from .errors import (
21
21
  MissingRequiredArgumentError,
22
22
  VerifyAccessTokenError,
23
23
  )
24
+ from .types import OnBehalfOfTokenResult
24
25
  from .utils import (
25
26
  calculate_jwk_thumbprint,
26
27
  fetch_jwks,
@@ -34,6 +35,7 @@ from .utils import (
34
35
 
35
36
  # Token Exchange constants
36
37
  TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" # noqa: S105
38
+ OBO_ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" # noqa: S105
37
39
  MAX_ARRAY_VALUES_PER_KEY = 20 # DoS protection for extra parameter arrays
38
40
 
39
41
  # OAuth parameter denylist - parameters that cannot be overridden via extras
@@ -232,7 +234,7 @@ class ApiClient:
232
234
  http_url: The HTTP URL (required for DPoP, also used for MCD resolver context)
233
235
 
234
236
  Returns:
235
- The decoded access token claims
237
+ The decoded access token claims, including `act` when present.
236
238
 
237
239
  Raises:
238
240
  MissingRequiredArgumentError: If required args are missing
@@ -412,7 +414,7 @@ class ApiClient:
412
414
  required_claims: Optional list of additional claim names that must be present
413
415
 
414
416
  Returns:
415
- The decoded token claims if valid.
417
+ The decoded token claims if valid, including `act` when present.
416
418
 
417
419
  Raises:
418
420
  MissingRequiredArgumentError: If no token is provided.
@@ -794,7 +796,7 @@ class ApiClient:
794
796
  Dictionary containing:
795
797
  - access_token (str): The Auth0 access token
796
798
  - expires_in (int): Token lifetime in seconds
797
- - 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
798
800
  - id_token (str, optional): OpenID Connect ID token
799
801
  - refresh_token (str, optional): Refresh token
800
802
  - scope (str, optional): Granted scopes
@@ -962,6 +964,64 @@ class ApiClient:
962
964
  exc
963
965
  )
964
966
 
967
+ async def get_token_on_behalf_of(
968
+ self,
969
+ access_token: str,
970
+ audience: str,
971
+ scope: Optional[str] = None,
972
+ ) -> OnBehalfOfTokenResult:
973
+ """
974
+ Exchange an Auth0 access token for another Auth0 access token targeting a downstream API
975
+ while acting on behalf of the same end user (OBO).
976
+
977
+ This is a convenience wrapper around get_token_by_exchange_profile() that fixes the
978
+ RFC 8693 token types for Auth0 access-token-to-access-token exchange.
979
+
980
+ Args:
981
+ access_token: The Auth0 access token to exchange
982
+ audience: Target API identifier for the exchanged access token
983
+ scope: Optional space-separated OAuth 2.0 scopes to request
984
+
985
+ Returns:
986
+ Dictionary containing:
987
+ - access_token (str): The exchanged Auth0 access token
988
+ - expires_in (int): Token lifetime in seconds
989
+ - expires_at (int): Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in
990
+ - scope (str, optional): Granted scopes
991
+ - token_type (str, optional): Token type (typically "Bearer")
992
+ - issued_token_type (str, optional): RFC 8693 issued token type identifier
993
+
994
+ Raises:
995
+ MissingRequiredArgumentError: If required parameters are missing
996
+ GetTokenByExchangeProfileError: If client credentials are not configured or validation fails
997
+ ApiError: If the token endpoint returns an error
998
+ """
999
+ if not audience:
1000
+ raise MissingRequiredArgumentError("audience")
1001
+
1002
+ result = await self.get_token_by_exchange_profile(
1003
+ subject_token=access_token,
1004
+ subject_token_type=OBO_ACCESS_TOKEN_TYPE,
1005
+ audience=audience,
1006
+ scope=scope,
1007
+ requested_token_type=OBO_ACCESS_TOKEN_TYPE,
1008
+ )
1009
+
1010
+ obo_result: OnBehalfOfTokenResult = {
1011
+ "access_token": result["access_token"],
1012
+ "expires_in": result["expires_in"],
1013
+ "expires_at": result["expires_at"],
1014
+ }
1015
+
1016
+ if "scope" in result:
1017
+ obo_result["scope"] = result["scope"]
1018
+ if "token_type" in result:
1019
+ obo_result["token_type"] = result["token_type"]
1020
+ if "issued_token_type" in result:
1021
+ obo_result["issued_token_type"] = result["issued_token_type"]
1022
+
1023
+ return obo_result
1024
+
965
1025
  # ===== Private Methods =====
966
1026
 
967
1027
  def _apply_extra(
@@ -27,8 +27,10 @@ class ApiClientOptions:
27
27
  dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP).
28
28
  dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30).
29
29
  dpop_iat_offset: Maximum age in seconds for DPoP proof iat claim (default: 300).
30
- client_id: Required for get_access_token_for_connection and get_token_by_exchange_profile.
31
- client_secret: Required for get_access_token_for_connection and get_token_by_exchange_profile.
30
+ client_id: Required for get_access_token_for_connection, get_token_by_exchange_profile,
31
+ and get_token_on_behalf_of.
32
+ client_secret: Required for get_access_token_for_connection, get_token_by_exchange_profile,
33
+ and get_token_on_behalf_of.
32
34
  timeout: HTTP timeout in seconds for token endpoint requests (default: 10.0).
33
35
  """
34
36
  def __init__(
@@ -6,6 +6,19 @@ from collections.abc import Awaitable, Callable
6
6
  from typing import Optional, TypedDict, Union
7
7
 
8
8
 
9
+ class ActClaim(TypedDict, total=False):
10
+ """
11
+ Actor claim carried by access tokens issued through delegation.
12
+
13
+ Attributes:
14
+ sub: The current actor for this step of the delegation chain.
15
+ act: The prior actor in the delegation chain, if present.
16
+ """
17
+
18
+ sub: str
19
+ act: "ActClaim"
20
+
21
+
9
22
  class DomainsResolverContext(TypedDict, total=False):
10
23
  """
11
24
  Context passed to domains resolver functions.
@@ -19,6 +32,27 @@ class DomainsResolverContext(TypedDict, total=False):
19
32
  request_headers: Optional[dict]
20
33
  unverified_iss: str
21
34
 
35
+
36
+ class OnBehalfOfTokenResult(TypedDict, total=False):
37
+ """
38
+ Result returned from an On Behalf Of token exchange.
39
+
40
+ Attributes:
41
+ access_token: The access token issued for the downstream API.
42
+ expires_in: Token lifetime in seconds.
43
+ expires_at: Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in.
44
+ scope: Granted scopes, if returned by Auth0.
45
+ token_type: Token type, if returned by Auth0.
46
+ issued_token_type: RFC 8693 issued token type, if returned by Auth0.
47
+ """
48
+
49
+ access_token: str
50
+ expires_in: int
51
+ expires_at: int
52
+ scope: str
53
+ token_type: str
54
+ issued_token_type: str
55
+
22
56
  DomainsResolver = Callable[
23
57
  [DomainsResolverContext], Union[list[str], Awaitable[list[str]]]
24
58
  ]