Skip to content

Commit bdbcef3

Browse files
feat(token-vault): add caching for federated connection token exchanges
Co-Authored-By: Claude <noreply@anthropic.com>
1 parent fcb1832 commit bdbcef3

9 files changed

Lines changed: 455 additions & 15 deletions

File tree

‎EXAMPLES.md‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,68 @@ async with httpx.AsyncClient() as client:
124124

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

127+
## Access Token for a Connection (Token Vault)
128+
129+
`get_access_token_for_connection()` exchanges the user's Auth0 access token for a token from a federated identity provider (such as Google) via Auth0's Token Vault.
130+
131+
Three things must exist on your Auth0 tenant before this works. The Token Vault grant must be enabled on your application, which must be a confidential (resource-server) client. The connection (for example `google-oauth2`) must be configured and enabled on that application. The user must have linked their account through Auth0's Connected Accounts flow. See the [Token Vault setup guide](https://auth0.com/docs/secure/tokens/token-vault/configure-token-vault) for the application setup and the [Connected Accounts guide](https://auth0.com/docs/secure/tokens/token-vault/connected-accounts-for-token-vault) for the user-linking step.
132+
133+
### Basic call
134+
135+
```python
136+
import httpx
137+
138+
from auth0_api_python import ApiClient, ApiClientOptions
139+
140+
api_client = ApiClient(ApiClientOptions(
141+
domain="your-tenant.auth0.com",
142+
audience="https://mcp-server.example.com",
143+
client_id="<AUTH0_CLIENT_ID>",
144+
client_secret="<AUTH0_CLIENT_SECRET>",
145+
))
146+
147+
incoming_access_token = "incoming-auth0-access-token"
148+
149+
result = await api_client.get_access_token_for_connection({
150+
"connection": "google-oauth2",
151+
"access_token": incoming_access_token,
152+
})
153+
154+
async with httpx.AsyncClient() as client:
155+
response = await client.get(
156+
"https://www.googleapis.com/calendar/v3/calendars/primary/events",
157+
headers={"Authorization": f"Bearer {result['access_token']}"},
158+
)
159+
```
160+
161+
### With caching
162+
163+
Pass a `token_store` to cache the connection token. The SDK caches by `sub` (caller) and `connection`, skipping the exchange on repeat calls for the same user and provider. Pass `verified=` with the result of `verify_access_token()` to reuse that verification rather than running it again inside the exchange.
164+
165+
```python
166+
from auth0_api_python import ApiClient, ApiClientOptions
167+
168+
# token_store is your AbstractTokenStore implementation.
169+
# See docs/TokenStorage.md for how to build one and for encryption details.
170+
api_client = ApiClient(ApiClientOptions(
171+
domain="your-tenant.auth0.com",
172+
audience="https://mcp-server.example.com",
173+
client_id="<AUTH0_CLIENT_ID>",
174+
client_secret="<AUTH0_CLIENT_SECRET>",
175+
token_store=your_token_store,
176+
))
177+
178+
incoming_access_token = "incoming-auth0-access-token"
179+
180+
# Verify once, then pass the result to avoid a second verification inside the exchange.
181+
verified = await api_client.verify_access_token(access_token=incoming_access_token)
182+
183+
result = await api_client.get_access_token_for_connection(
184+
{"connection": "google-oauth2", "access_token": incoming_access_token},
185+
verified=verified,
186+
)
187+
```
188+
127189
## Inspecting Delegation After Token Verification
128190

129191
When a downstream API or `MCP` server receives an access token that may have been issued through

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,8 @@ async def main():
122122
asyncio.run(main())
123123
```
124124

125+
When a `token_store` is configured on `ApiClientOptions`, the connection token is cached by caller and connection, skipping the exchange on repeat calls. Pass `verified=` to reuse an already-verified token rather than verifying a second time. See [Access Token for a Connection](EXAMPLES.md#access-token-for-a-connection-token-vault) for a full example and setup requirements.
126+
125127
More info https://auth0.com/docs/secure/tokens/token-vault
126128

127129
### 5. Custom Token Exchange (Early Access)

‎docs/TokenStorage.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,6 @@
11
# Token Storage
22

3-
The SDK can cache access tokens it mints on the caller's behalf. Currently this covers tokens
4-
returned by `get_token_on_behalf_of()`. This is separate from the `CacheAdapter` described in the
5-
[Caching Guide](Caching.md), which only caches OIDC discovery metadata and JWKS keys, never a live
6-
bearer token.
3+
The SDK can cache access tokens it mints on the caller's behalf. The same store covers tokens returned by `get_token_on_behalf_of()`, M2M tokens from `get_client_credentials_token()`, and federated-connection tokens from `get_access_token_for_connection()`. This is separate from the `CacheAdapter` described in the [Caching Guide](Caching.md), which only caches OIDC discovery metadata and JWKS keys, never a live bearer token.
74

85
## Default Behavior
96

@@ -14,6 +11,10 @@ To enable caching, pass a `token_store` to `ApiClientOptions`. Once a store is c
1411
automatically builds a cache key from the incoming token and no additional argument is needed per
1512
call.
1613

14+
## Cache Key Shapes
15+
16+
The store uses a different cache key for each exchange method. OBO tokens from `get_token_on_behalf_of()` use the most detailed key, incorporating verified issuer, incoming client, exchange tenant and client, audience, organization, session, and granted scopes (see [On Behalf Of Exchange with Caching](#on-behalf-of-exchange-with-caching) for the full design). M2M tokens from `get_client_credentials_token()` are keyed on tenant, client, audience, and the requested scope set because the server is the identity and there is no user `sub`. Connection tokens from `get_access_token_for_connection()` are keyed on tenant, client, `sub` (the caller), and `connection` with no scope dimension, since the federated provider determines what scopes apply.
17+
1718
## On Behalf Of Exchange with Caching
1819

1920
The following example verifies an incoming token and exchanges for a downstream token. The result

‎src/auth0_api_python/__init__.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313
ApiError,
1414
ConfigurationError,
1515
DomainsResolverError,
16+
GetAccessTokenForConnectionError,
1617
GetClientCredentialsTokenError,
1718
GetTokenByExchangeProfileError,
1819
TokenStoreError,
20+
VerifyAccessTokenError,
1921
)
2022
from .token_store import (
2123
AbstractTokenStore,
@@ -41,9 +43,11 @@
4143
"DomainsResolver",
4244
"DomainsResolverContext",
4345
"DomainsResolverError",
46+
"GetAccessTokenForConnectionError",
4447
"GetClientCredentialsTokenError",
4548
"GetTokenByExchangeProfileError",
4649
"TokenStoreError",
50+
"VerifyAccessTokenError",
4751
"get_current_actor",
4852
"get_delegation_chain",
4953
"InMemoryCache",

‎src/auth0_api_python/_internal/cache_keys.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def m2m_cache_key(*, tenant: str, client_id: str, audience: str, scopes: Optiona
140140
return hashlib.sha256(fields.encode()).hexdigest()
141141

142142

143-
def token_vault_cache_key(sub: str, connection: str) -> str:
144-
"""Cache key for a Token Vault exchange, scoped to caller and connection."""
145-
fields = _FIELD_SEPARATOR.join(["token_vault", sub, connection])
143+
def token_vault_cache_key(*, sub: str, connection: str, tenant: str, client_id: str) -> str:
144+
"""Cache key for a Token Vault exchange, scoped to tenant, client, caller, and connection."""
145+
fields = _FIELD_SEPARATOR.join(["token_vault", tenant, client_id, sub, connection])
146146
return hashlib.sha256(fields.encode()).hexdigest()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Caching for Token Vault (federated connection) exchanges: lookup and write."""
2+
3+
import logging
4+
import time
5+
from typing import Optional
6+
7+
from ..errors import TokenStoreError
8+
from ..token_store import AbstractTokenStore, TokenSet
9+
from .cache_keys import _normalized_scopes, token_vault_cache_key
10+
11+
12+
class TokenVaultCache:
13+
"""Caches Token Vault exchange results in a token store, keyed by caller and connection.
14+
15+
ApiClient builds one TokenVaultCache when a token_store is configured and delegates
16+
every cache decision to it.
17+
"""
18+
19+
def __init__(self, token_store: AbstractTokenStore) -> None:
20+
self._store = token_store
21+
22+
def cache_key(self, tenant: str, client_id: str, sub: Optional[str], connection: str) -> Optional[str]:
23+
"""Return the cache key, or None when sub is absent (cache skipped with a warning)."""
24+
if not sub:
25+
logging.warning("Access token has no usable sub claim, skipping Token Vault cache")
26+
return None
27+
return token_vault_cache_key(sub=sub, connection=connection, tenant=tenant, client_id=client_id)
28+
29+
async def lookup(self, key: str) -> Optional[dict]:
30+
"""Return a live cached token or None. Miss cases: absent, malformed, expired."""
31+
cached: Optional[TokenSet] = None
32+
try:
33+
cached = await self._store.get(key)
34+
except Exception as exc:
35+
store_err = TokenStoreError("Token store read failed", cause=exc)
36+
logging.warning("Token store read failed, treating as cache miss: %s", store_err.cause)
37+
return None
38+
39+
if cached is None:
40+
return None
41+
if "access_token" not in cached or "expires_at" not in cached:
42+
logging.warning("Token store returned a malformed entry, treating as cache miss")
43+
return None
44+
if cached["expires_at"] <= int(time.time()):
45+
return None
46+
47+
result = {
48+
"access_token": cached["access_token"],
49+
"expires_in": cached["expires_at"] - int(time.time()),
50+
"expires_at": cached["expires_at"],
51+
}
52+
granted = cached.get("granted_scopes")
53+
if granted:
54+
result["scope"] = granted
55+
return result
56+
57+
async def write(self, key: str, result: dict) -> None:
58+
"""Store a freshly exchanged connection token. Failures are logged and swallowed."""
59+
entry: TokenSet = {
60+
"access_token": result["access_token"],
61+
"expires_at": result["expires_at"],
62+
"granted_scopes": _normalized_scopes(result.get("scope")),
63+
}
64+
try:
65+
await self._store.set(key, entry)
66+
except Exception as exc:
67+
store_err = TokenStoreError("Token store write failed", cause=exc)
68+
logging.warning("Token store write failed, token still returned: %s", store_err.cause)

‎src/auth0_api_python/api_client.py‎

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from ._internal.cache_keys import m2m_cache_key
1111
from ._internal.obo_cache import OboCache
12+
from ._internal.token_vault_cache import TokenVaultCache
1213
from .cache import InMemoryCache
1314
from .config import ApiClientOptions
1415
from .errors import (
@@ -156,6 +157,11 @@ def __init__(self, options: ApiClientOptions):
156157
if options.token_store is not None
157158
else None
158159
)
160+
self._token_vault_cache = (
161+
TokenVaultCache(options.token_store)
162+
if options.token_store is not None
163+
else None
164+
)
159165

160166
self._cache_ttl = options.cache_ttl_seconds
161167

@@ -717,18 +723,27 @@ async def verify_dpop_proof(
717723

718724
return claims
719725

720-
async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict[str, Any]:
726+
async def get_access_token_for_connection(
727+
self,
728+
options: dict[str, Any],
729+
*,
730+
verified: Optional[VerifiedToken] = None,
731+
) -> dict[str, Any]:
721732
"""
722733
Retrieves a token for a connection.
723734
724735
Args:
725736
options: Options for retrieving an access token for a connection.
726737
Must include 'connection' and 'access_token' keys.
727738
May optionally include 'login_hint'.
739+
verified: The already-verified token, supplied by a caller that has verified it (for
740+
example an MCP server). When omitted and a token_store is configured, the
741+
token is verified here before any cache lookup.
728742
729743
Raises:
730744
GetAccessTokenForConnectionError: If there was an issue requesting the access token.
731745
ApiError: If the token exchange endpoint returns an error.
746+
VerifyAccessTokenError: If a store is configured and either verified is omitted and the token fails verification, or verified is supplied but does not match the access token being exchanged.
732747
733748
Returns:
734749
Dictionary containing the token response with access_token, expires_in, and scope.
@@ -737,8 +752,8 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict
737752
SUBJECT_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" # noqa S105
738753
REQUESTED_TOKEN_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "http://auth0.com/oauth/token-type/federated-connection-access-token" # noqa S105
739754
GRANT_TYPE_FEDERATED_CONNECTION_ACCESS_TOKEN = "urn:auth0:params:oauth:grant-type:token-exchange:federated-connection-access-token" # noqa S105
740-
connection = options.get("connection")
741-
access_token = options.get("access_token")
755+
connection = options.get("connection", "")
756+
access_token = options.get("access_token", "")
742757

743758
if not connection:
744759
raise MissingRequiredArgumentError("connection")
@@ -751,6 +766,28 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict
751766
if not client_id or not client_secret:
752767
raise GetAccessTokenForConnectionError("You must configure the SDK with a client_id and client_secret to use get_access_token_for_connection.")
753768

769+
cache_key = None
770+
if self._token_vault_cache is not None:
771+
if verified is None:
772+
claims = await self.verify_access_token(access_token)
773+
verified = VerifiedToken(access_token=access_token, claims=claims)
774+
elif verified.access_token != access_token:
775+
# Claims must belong to the token being exchanged or a caller could read another token's entry.
776+
raise VerifyAccessTokenError(
777+
"verified token does not match the access token being exchanged"
778+
)
779+
cache_key = self._token_vault_cache.cache_key(
780+
tenant=self.options.domain or "",
781+
client_id=self.options.client_id or "",
782+
sub=verified.claims.get("sub"),
783+
connection=connection,
784+
)
785+
786+
if cache_key is not None:
787+
hit = await self._token_vault_cache.lookup(cache_key)
788+
if hit is not None:
789+
return hit
790+
754791
metadata = await self._discover()
755792

756793
token_endpoint = metadata.get("token_endpoint")
@@ -809,12 +846,18 @@ async def get_access_token_for_connection(self, options: dict[str, Any]) -> dict
809846
except (TypeError, ValueError):
810847
raise ApiError("invalid_response", "expires_in is not an integer.", 502)
811848

812-
return {
849+
result = {
813850
"access_token": access_token,
851+
"expires_in": expires_in,
814852
"expires_at": int(time.time()) + expires_in,
815853
"scope": token_endpoint_response.get("scope", "")
816854
}
817855

856+
if cache_key is not None:
857+
await self._token_vault_cache.write(cache_key, result)
858+
859+
return result
860+
818861
except httpx.TimeoutException as exc:
819862
raise ApiError(
820863
"timeout_error",

0 commit comments

Comments
 (0)