Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,41 @@ result = await api_client.get_token_on_behalf_of(
See the **[Token Storage Guide](docs/TokenStorage.md)** for a full working example, how to
implement a Redis-backed store, and the built-in encryption helpers.

## Client Credentials for Server-to-Server Calls

Use `get_client_credentials_token()` to obtain an M2M access token for server-to-server calls
using the OAuth 2.0 client credentials grant. The SDK authenticates via HTTP Basic and caches
the result in the configured `token_store` (keyed by audience and scope set) so subsequent calls
within the token's lifetime skip the network round-trip.

```python
import httpx

from auth0_api_python import ApiClient, ApiClientOptions

api_client = ApiClient(ApiClientOptions(
domain="your-tenant.auth0.com",
audience="https://mcp-server.example.com",
client_id="<AUTH0_CLIENT_ID>",
client_secret="<AUTH0_CLIENT_SECRET>",
))

result = await api_client.get_client_credentials_token(
audience="https://downstream-api.example.com",
scope="read:data",
)

# result always has "access_token", "expires_in", "expires_at".
# "scope" is not guaranteed in the response and may be absent.
async with httpx.AsyncClient() as client:
response = await client.get(
"https://downstream-api.example.com/data",
headers={"Authorization": f"Bearer {result['access_token']}"},
)
```

More info: [Client Credentials Flow](https://auth0.com/docs/get-started/authentication-and-authorization-flow/client-credentials-flow)

## Inspecting Delegation After Token Verification

When a downstream API or `MCP` server receives an access token that may have been issued through
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,21 @@ Configuring a `token_store` on `ApiClientOptions` caches the exchanged token, so
the same caller, audience, organization, scopes, and session reuses it instead of exchanging again.
With no `token_store`, every call performs a fresh exchange, matching the existing behavior above.

#### Client Credentials for Server-to-Server Calls

Use `get_client_credentials_token()` to obtain an M2M access token for server-to-server calls
using the OAuth 2.0 client credentials grant.

```python
result = await api_client.get_client_credentials_token(
audience="https://downstream-api.example.com",
scope="read:data",
)
# call downstream API with result["access_token"]
```

See the **[Client Credentials example](EXAMPLES.md#client-credentials-for-server-to-server-calls)** for a full example that calls a downstream API, plus the caching behavior.

#### Inspecting Delegation After Token Verification

When a downstream API or `MCP` server receives an access token that may have been issued through
Expand Down
4 changes: 4 additions & 0 deletions src/auth0_api_python/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ApiError,
ConfigurationError,
DomainsResolverError,
GetClientCredentialsTokenError,
GetTokenByExchangeProfileError,
MissingOrganizationError,
OrganizationNotAllowedError,
Expand All @@ -26,6 +27,7 @@
VerifiedToken,
)
from .types import (
ClientCredentialsTokenResult,
DomainsResolver,
DomainsResolverContext,
OnBehalfOfTokenResult,
Expand All @@ -36,10 +38,12 @@
"ApiClientOptions",
"ApiError",
"CacheAdapter",
"ClientCredentialsTokenResult",
"ConfigurationError",
"DomainsResolver",
"DomainsResolverContext",
"DomainsResolverError",
"GetClientCredentialsTokenError",
"GetTokenByExchangeProfileError",
"TokenStoreError",
"get_current_actor",
Expand Down
150 changes: 149 additions & 1 deletion src/auth0_api_python/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import httpx
from authlib.jose import JsonWebKey, JsonWebToken

from ._internal.cache_keys import m2m_cache_key
from ._internal.obo_cache import OboCache
from .cache import InMemoryCache
from .config import ApiClientOptions
Expand All @@ -16,20 +17,22 @@
ConfigurationError,
DomainsResolverError,
GetAccessTokenForConnectionError,
GetClientCredentialsTokenError,
GetTokenByExchangeProfileError,
InvalidAuthSchemeError,
InvalidDpopProofError,
MissingAuthorizationError,
MissingOrganizationError,
MissingRequiredArgumentError,
OrganizationNotAllowedError,
TokenStoreError,
VerifyAccessTokenError,
)
from .token_store import (
IndexedTokenStore,
VerifiedToken,
)
from .types import OnBehalfOfTokenResult
from .types import ClientCredentialsTokenResult, OnBehalfOfTokenResult
from .utils import (
calculate_jwk_thumbprint,
fetch_jwks,
Expand Down Expand Up @@ -1123,6 +1126,151 @@ async def get_token_on_behalf_of(

return obo_result

async def get_client_credentials_token(
self,
audience: str,
scope: Optional[str] = None,
) -> ClientCredentialsTokenResult:
"""
Obtain a client credentials (M2M) access token for a server-to-server call.

Args:
audience: Target API identifier for the access token
scope: Optional space-separated OAuth 2.0 scopes to request

Returns:
Dictionary containing:
- access_token (str): The access token
- expires_in (int): Token lifetime in seconds
- expires_at (int): Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in
- scope (str, optional): Granted scopes, if returned by Auth0
- token_type (str, optional): Token type, if returned by Auth0

Raises:
MissingRequiredArgumentError: If audience is not provided
GetClientCredentialsTokenError: If client credentials are not configured or token endpoint is missing
ApiError: If the token endpoint returns an error
"""
if not audience:
raise MissingRequiredArgumentError("audience")

client_id = self.options.client_id
client_secret = self.options.client_secret
if not client_id or not client_secret:
raise GetClientCredentialsTokenError(
"Client credentials are required to use get_client_credentials_token. "
"Configure client_id and client_secret in ApiClientOptions to use this feature"
)

# Check cache before making a network call
cache_key = None
if self._token_store is not None:
cache_key = m2m_cache_key(tenant=self.options.domain, client_id=client_id, audience=audience, scopes=scope)

cached = None
try:
cached = await self._token_store.get(cache_key)
except Exception as exc:
store_err = TokenStoreError("Token store read failed", cause=exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

TokenStoreError(...) is built here only so its .cause can be logged, and logging goes through the root logger. This matches the existing convention so it is consistency only, but logging exc directly and using logging.getLogger(__name__) would be a bit cleaner.

logging.warning("Token store read failed, treating as cache miss: %s", store_err.cause)

if cached is not None:
if "access_token" not in cached or "expires_at" not in cached:
logging.warning("Token store returned a malformed entry, treating as cache miss")
elif cached["expires_at"] > int(time.time()):
hit: ClientCredentialsTokenResult = {
"access_token": cached["access_token"],
"expires_in": cached["expires_at"] - int(time.time()),
"expires_at": cached["expires_at"],
}
if cached.get("granted_scopes"):
hit["scope"] = cached["granted_scopes"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stored entry keeps only access_token, expires_at and granted_scopes, so a cache hit never returns token_type and drops scope when the granted value is falsy.

A fresh exchange includes both, so the second call for the same audience and scope returns a different dict than the first. The success test reads result["token_type"], which would KeyError on a hit.

Should we store and restore token_type, and gate scope on key presence rather than truthiness so both paths match?

return hit

metadata = await self._discover()
token_endpoint = metadata.get("token_endpoint")
if not token_endpoint:
raise GetClientCredentialsTokenError(
"Token endpoint missing in OIDC metadata. "
"Verify your domain configuration and that the OIDC discovery endpoint is accessible"
)

params: dict[str, str] = {
"grant_type": "client_credentials",
"audience": audience,
}

if scope:
params["scope"] = scope

try:
async with httpx.AsyncClient(timeout=httpx.Timeout(self.options.timeout)) as client:
response = await client.post(
token_endpoint,
data=params,
auth=(client_id, client_secret)
)

if response.status_code != 200:
error_data = {}
try:
content_type = response.headers.get("content-type", "").lower()
if "json" in content_type:
error_data = response.json()
except ValueError:
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
pass # Ignore JSON parse errors, use generic error message below

raise ApiError(
error_data.get("error", "client_credentials_error"),
error_data.get("error_description", "Failed to get client credentials token."),
response.status_code
)

token_response = response.json()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is inside a try that only catches httpx errors, but response.json() and the token_response["expires_in"] / ["access_token"] indexing have no guard.

A non JSON body raises ValueError and a missing field raises KeyError, both of which escape instead of becoming an ApiError. expires_in is also used without int() coercion or a negative check.

The sibling exchange method already does all of this. Can we validate access_token is a non empty str and coerce expires_in with int(), raising ApiError on failure?


expires_in = token_response["expires_in"]
cc_result = {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

cc_result = {...} is a plain dict here where ClientCredentialsTokenResult is declared. The hit path and the OBO equivalent both annotate, so cc_result: ClientCredentialsTokenResult = {...} would be consistent.

"access_token": token_response["access_token"],
"expires_in": expires_in,
"expires_at": int(time.time()) + expires_in,
}

# scope is not guaranteed in the client credentials response
if "scope" in token_response:
cc_result["scope"] = token_response["scope"]
if "token_type" in token_response:
cc_result["token_type"] = token_response["token_type"]

except httpx.TimeoutException as exc:
raise ApiError(
"timeout_error",
f"Request to token endpoint timed out: {str(exc)}",
504,
exc
)
except httpx.HTTPError as exc:
raise ApiError(
"network_error",
f"Network error occurred: {str(exc)}",
502,
exc
)

if cache_key is not None:
try:
entry: dict[str, Any] = {
"access_token": cc_result["access_token"],
"expires_at": cc_result["expires_at"],
}
if "scope" in cc_result:
entry["granted_scopes"] = cc_result["scope"]
await self._token_store.set(cache_key, entry)
except Exception as exc:
store_err = TokenStoreError("Token store write failed", cause=exc)
logging.warning("Token store write failed, token still returned: %s", store_err.cause)

return cc_result

# ===== Private Methods =====

def _apply_extra(
Expand Down
10 changes: 10 additions & 0 deletions src/auth0_api_python/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,16 @@ def get_error_code(self) -> str:
return "get_token_by_exchange_profile_error"


class GetClientCredentialsTokenError(BaseAuthError):
"""Error raised when a client credentials token request fails before the network call."""

def get_status_code(self) -> int:
return 400

def get_error_code(self) -> str:
return "get_client_credentials_token_error"


class ApiError(BaseAuthError):
"""
Error raised when an API request to Auth0 fails.
Expand Down
19 changes: 19 additions & 0 deletions src/auth0_api_python/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,25 @@ class OnBehalfOfTokenResult(TypedDict, total=False):
token_type: str
issued_token_type: str


class ClientCredentialsTokenResult(TypedDict, total=False):
"""
Result returned from a client credentials (M2M) token request.

Attributes:
access_token: The access token issued for the target API.
expires_in: Token lifetime in seconds.
expires_at: Absolute expiration time as a Unix timestamp in seconds, calculated by the SDK from expires_in.
scope: Granted scopes, if returned by Auth0.
token_type: Token type, if returned by Auth0.
"""

access_token: str
expires_in: int
expires_at: int
scope: str
token_type: str

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit:

Only one blank line separates ClientCredentialsTokenResult from the following module level assignment, PEP8 E305 wants two. Purely cosmetic.

DomainsResolver = Callable[
[DomainsResolverContext], Union[list[str], Awaitable[list[str]]]
]
Expand Down
Loading
Loading