Repository navigation
feat: add client credentials token support for M2M - #138
kishore7snehil wants to merge 3 commits into
Conversation
| "expires_at": cached["expires_at"], | ||
| } | ||
| if cached.get("granted_scopes"): | ||
| hit["scope"] = cached["granted_scopes"] |
There was a problem hiding this comment.
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?
| response.status_code | ||
| ) | ||
|
|
||
| token_response = response.json() |
There was a problem hiding this comment.
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?
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_get_client_credentials_token_success(mock_discovery, api_client_confidential, httpx_mock): |
There was a problem hiding this comment.
The no token_store happy path (every test uses a client with a store), a different scope for the same audience being a cache miss (the scope part of the key is unverified), the missing token_endpoint branch, and the SDK side expiry guard (the current expiry test relies on the in memory store self expiring).
Can we add these?
| token_response = response.json() | ||
|
|
||
| expires_in = token_response["expires_in"] | ||
| cc_result = { |
There was a problem hiding this comment.
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.
| try: | ||
| cached = await self._token_store.get(cache_key) | ||
| except Exception as exc: | ||
| store_err = TokenStoreError("Token store read failed", cause=exc) |
There was a problem hiding this comment.
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.
| expires_at: int | ||
| scope: str | ||
| token_type: str | ||
|
|
There was a problem hiding this comment.
Nit:
Only one blank line separates ClientCredentialsTokenResult from the following module level assignment, PEP8 E305 wants two. Purely cosmetic.
fcb1832 to
6576276
Compare
42680cd to
d57457d
Compare
Add an ApiClient method to obtain a client credentials (M2M) access token for server-to-server calls using the OAuth 2.0 client credentials grant. The method authenticates via HTTP Basic and caches the result in a configured token_store keyed by audience and scope set, so a repeat call within the token's lifetime skips the network round-trip. Add the ClientCredentialsTokenResult type and GetClientCredentialsTokenError, and document the method in the README and examples. Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
Cache the scope Auth0 returned alongside the token and include it on a cache hit, so a hit matches the original exchange.
d57457d to
75faa2d
Compare
6576276 to
a9668aa
Compare
📋 Changes
This PR adds
get_client_credentials_token()toApiClientfor obtaining machine-to-machine access tokens with the OAuth 2.0 client credentials grant. When a token store is configured, the result is cached so repeat calls within the token's lifetime skip the network round-trip.✨ Features
get_client_credentials_token(audience, scope=None)method that authenticates with HTTP Basic using the configuredclient_idandclient_secret.token_storeis set, tokens are cached per tenant, client, audience, and scope set. Store read and write failures are logged and fall back to a fresh exchange.ClientCredentialsTokenResultTypedDict.🔧 API Changes
ApiClient.get_client_credentials_token()ClientCredentialsTokenResult(TypedDict)GetClientCredentialsTokenError, raised when client credentials are not configured or the token endpoint is missing from discovery metadata📖 Documentation
README.mdwith a client credentials sectionEXAMPLES.mdwith a client credentials example🧪 Testing
Contributor Checklist