Skip to content

Commit 8a067b5

Browse files
feat: add server-side token storage with OBO caching
Add pluggable server-side token storage and use it to cache On Behalf Of exchanges. When a token_store is configured, get_token_on_behalf_of() reuses previously exchanged tokens instead of hitting the token endpoint on every call. - AbstractTokenStore ABC with JWE at-rest encryption helpers - IndexedTokenStore for non-strict scope matching - OBO cache wiring in get_token_on_behalf_of() - scope_matching option: strict (exact match) or non_strict (coverage) - TokenSet, TokenIndexMember TypedDicts and VerifiedToken dataclass - TokenStoreError (status 500) for backend failures
1 parent 8f87351 commit 8a067b5

17 files changed

Lines changed: 2439 additions & 136 deletions

‎EXAMPLES.md‎

Lines changed: 29 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,35 @@ asyncio.run(exchange_on_behalf_of())
6060
In the current implementation, `get_token_on_behalf_of()` forwards the incoming access token as
6161
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.
6262

63+
### Caching the Exchanged Token
64+
65+
To cache the exchanged token, pass a `token_store` when constructing `ApiClient`. Caching activates automatically once a store is configured. The SDK reads `sub` from the incoming token to build the cache key, so no additional argument is needed on each call.
66+
67+
```python
68+
from auth0_api_python import ApiClient, ApiClientOptions
69+
70+
# token_store is your AbstractTokenStore implementation (e.g. Redis-backed).
71+
# See docs/TokenStorage.md for how to build one.
72+
api_client = ApiClient(ApiClientOptions(
73+
domain="your-tenant.auth0.com",
74+
audience="https://mcp-server.example.com",
75+
client_id="<AUTH0_CLIENT_ID>",
76+
client_secret="<AUTH0_CLIENT_SECRET>",
77+
token_store=your_token_store,
78+
))
79+
80+
claims = await api_client.verify_access_token(access_token=incoming_access_token)
81+
82+
result = await api_client.get_token_on_behalf_of(
83+
access_token=incoming_access_token,
84+
audience="https://calendar-api.example.com",
85+
scope="calendar:read calendar:write",
86+
)
87+
```
88+
89+
See the **[Token Storage Guide](docs/TokenStorage.md)** for a full working example, how to
90+
implement a Redis-backed store, and the built-in encryption helpers.
91+
6392
## Inspecting Delegation After Token Verification
6493

6594
When a downstream API or `MCP` server receives an access token that may have been issued through
@@ -273,74 +302,3 @@ async def verify_dpop_token(access_token, dpop_proof, http_method, http_url):
273302
"proof_claims": proof_claims
274303
}
275304
```
276-
277-
## Anonymous Callers
278-
279-
Anonymous Sessions give a visitor an Auth0 identity before they log in. The access token issued for an anonymous session is a standard Auth0 Bearer JWT, so this SDK validates it like any other token. The one difference is the `sub` claim, which starts with `anon@`.
280-
281-
An anonymous token is verified like any other. It must be issued for this API's audience. To treat anonymous callers differently, or block them, check the `sub` claim after verifying the token. The SDK does not make that authorization decision for you.
282-
283-
### Serve everyone, branch in the handler
284-
285-
```python
286-
from auth0_api_python import ApiClient, ApiClientOptions
287-
288-
async def handle_cart(headers):
289-
api_client = ApiClient(ApiClientOptions(
290-
domain="your-tenant.auth0.com",
291-
audience="https://api.example.com"
292-
))
293-
294-
claims = await api_client.verify_request(headers=headers)
295-
is_anonymous = claims.get("sub", "").startswith("anon@")
296-
297-
if is_anonymous:
298-
return {"cart": load_guest_cart(claims["sub"])}
299-
return {"cart": load_user_cart(claims["sub"])}
300-
```
301-
302-
> [!NOTE]
303-
> These snippets construct `ApiClient` inside the handler for clarity. In production, build it once at startup and reuse it, or pass a shared `cache_adapter`, so JWKS and discovery caches persist across requests.
304-
305-
### Block anonymous callers on a specific route
306-
307-
```python
308-
from auth0_api_python import ApiClient, ApiClientOptions
309-
310-
async def handle_checkout(headers):
311-
api_client = ApiClient(ApiClientOptions(
312-
domain="your-tenant.auth0.com",
313-
audience="https://api.example.com"
314-
))
315-
316-
claims = await api_client.verify_request(headers=headers)
317-
if claims.get("sub", "").startswith("anon@"):
318-
raise PermissionError("Anonymous callers are not allowed on this route")
319-
320-
return {"order": create_order(claims["sub"])}
321-
```
322-
323-
> [!NOTE]
324-
> Blocking an anonymous caller should return an HTTP `403 Forbidden`. Replace `PermissionError` with your framework's error type.
325-
326-
### Block anonymous callers everywhere
327-
328-
The SDK has no global "reject anonymous" switch. Centralize the check in whatever shared layer your framework uses for auth (middleware, a FastAPI dependency, a decorator).
329-
330-
```python
331-
from auth0_api_python import ApiClient, ApiClientOptions
332-
333-
async def require_logged_in_user(headers):
334-
api_client = ApiClient(ApiClientOptions(
335-
domain="your-tenant.auth0.com",
336-
audience="https://api.example.com"
337-
))
338-
339-
claims = await api_client.verify_request(headers=headers)
340-
if claims.get("sub", "").startswith("anon@"):
341-
raise PermissionError("Anonymous callers are not allowed")
342-
return claims
343-
```
344-
345-
> [!NOTE]
346-
> The `anon@` prefix on `sub` is the only signal that distinguishes an anonymous caller from a logged-in user.

‎README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,10 @@ token as the `subject_token` and relies on Auth0 to handle any DPoP-specific beh
251251
The OBO result only includes access-token-oriented fields. It does not expose `id_token` or
252252
`refresh_token`.
253253

254+
Configuring a `token_store` on `ApiClientOptions` caches the exchanged token, so a repeat call for
255+
the same caller, audience, organization, scopes, and session reuses it instead of exchanging again.
256+
With no `token_store`, every call performs a fresh exchange, matching the existing behavior above.
257+
254258
#### Inspecting Delegation After Token Verification
255259

256260
When a downstream API or `MCP` server receives an access token that may have been issued through
@@ -407,6 +411,7 @@ For hybrid mode (migration scenarios), resolver patterns, error handling, and ca
407411

408412
- **[Multi-Custom Domain Guide](docs/MultipleCustomDomain.md)** - Configuration modes, resolver patterns, migration, error handling
409413
- **[Caching Guide](docs/Caching.md)** - Cache tuning, custom adapters (Redis, Memcached)
414+
- **[Token Storage Guide](docs/TokenStorage.md)** - Caching OBO exchanges, custom TokenStore backends, at-rest encryption
410415

411416
### 8. Anonymous Callers
412417

‎docs/TokenStorage.md‎

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Token Storage
2+
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.
7+
8+
## Default Behavior
9+
10+
Caching is disabled by default. Without a `token_store`, every call to `get_token_on_behalf_of()`
11+
performs a fresh exchange and nothing is stored.
12+
13+
To enable caching, pass a `token_store` to `ApiClientOptions`. Once a store is configured, the SDK
14+
automatically builds a cache key from the incoming token and no additional argument is needed per
15+
call.
16+
17+
## On Behalf Of Exchange with Caching
18+
19+
The following example verifies an incoming token and exchanges for a downstream token. The result
20+
is cached so a second call for the same caller, audience, org, and scopes returns the cached token
21+
without hitting Auth0 again.
22+
23+
```python
24+
import asyncio
25+
import httpx
26+
27+
from auth0_api_python import ApiClient, ApiClientOptions
28+
29+
async def exchange_on_behalf_of_cached(your_token_store):
30+
api_client = ApiClient(ApiClientOptions(
31+
domain="your-tenant.auth0.com",
32+
audience="https://mcp-server.example.com",
33+
client_id="<AUTH0_CLIENT_ID>",
34+
client_secret="<AUTH0_CLIENT_SECRET>",
35+
token_store=your_token_store,
36+
))
37+
38+
incoming_access_token = "incoming-auth0-access-token"
39+
40+
# With a store configured, the exchange verifies the incoming token itself, so there is
41+
# no need to call verify_access_token separately here.
42+
result = await api_client.get_token_on_behalf_of(
43+
access_token=incoming_access_token,
44+
audience="https://calendar-api.example.com",
45+
scope="calendar:read calendar:write",
46+
)
47+
48+
async with httpx.AsyncClient() as client:
49+
downstream_response = await client.get(
50+
"https://calendar-api.example.com/events",
51+
headers={"Authorization": f"Bearer {result['access_token']}"}
52+
)
53+
54+
downstream_response.raise_for_status()
55+
return downstream_response.json()
56+
```
57+
58+
The cached entry is scoped to the caller (`sub`), the target `audience`, the `org_id`, the
59+
scopes it was granted, and the session the incoming token belongs to. Different callers or
60+
sessions never share a cached token.
61+
62+
## Implementing a Token Store
63+
64+
To supply a store, subclass `AbstractTokenStore` and implement three async methods: `get`, `set`,
65+
and `delete`. The base class requires a `secret` at construction and provides `encrypt` and
66+
`decrypt` helpers that your methods can call to protect tokens at rest.
67+
68+
### Redis example
69+
70+
```python
71+
import time
72+
from typing import Optional
73+
74+
import redis.asyncio as redis
75+
76+
from auth0_api_python import AbstractTokenStore, TokenSet
77+
78+
79+
class RedisTokenStore(AbstractTokenStore):
80+
def __init__(self, redis_client, *, secret: str):
81+
super().__init__(secret=secret)
82+
self.redis = redis_client
83+
84+
async def get(self, key: str) -> Optional[TokenSet]:
85+
raw = await self.redis.get(key)
86+
if raw is None:
87+
return None
88+
return self.decrypt(key, raw)
89+
90+
async def set(self, key: str, value: TokenSet) -> None:
91+
encrypted = self.encrypt(key, value)
92+
ttl = max(value["expires_at"] - int(time.time()), 0)
93+
await self.redis.set(key, encrypted, ex=ttl)
94+
95+
async def delete(self, key: str) -> None:
96+
await self.redis.delete(key)
97+
98+
99+
# Usage
100+
redis_client = redis.Redis(host="localhost", port=6379, db=0)
101+
102+
api_client = ApiClient(ApiClientOptions(
103+
domain="your-tenant.auth0.com",
104+
audience="https://mcp-server.example.com",
105+
client_id="<AUTH0_CLIENT_ID>",
106+
client_secret="<AUTH0_CLIENT_SECRET>",
107+
token_store=RedisTokenStore(redis_client, secret="<YOUR_ENCRYPTION_SECRET>"),
108+
))
109+
```
110+
111+
### Encryption
112+
113+
`self.encrypt(key, value)` and `self.decrypt(key, data)` are provided by `AbstractTokenStore`.
114+
They use HKDF-SHA256 to derive a per-entry encryption key from `secret` and the cache key, then
115+
wrap the token in a JWE using `alg: dir` and `enc: A256CBC-HS512`. A fresh random `kid` is
116+
generated on every write, so two encryptions of the same value produce different ciphertext.
117+
118+
`secret` must be kept outside your codebase, for example in an environment variable or a secrets
119+
manager. Rotating it invalidates all existing cached entries, which is safe because the SDK falls
120+
back to a fresh exchange on any cache miss or decryption failure.
121+
122+
## Matching cached tokens by scope
123+
124+
By default the SDK only reuses a cached token when a later call asks for exactly the same scopes.
125+
This is the `strict` setting of `scope_matching` on `ApiClientOptions`, and it works with any
126+
store that implements `get`, `set`, and `delete`.
127+
128+
Set `scope_matching="non_strict"` when a broader token should satisfy a narrower request. If an
129+
earlier exchange was granted `calendar:read calendar:write` and a later call only needs
130+
`calendar:read`, non_strict returns the cached token instead of exchanging again, because the
131+
granted scopes already cover what was asked for.
132+
133+
To do that without one scope set evicting another, non_strict keeps every distinct token plus an
134+
index of the scopes each one was granted, so any cached token whose scopes cover the request can
135+
be reused. The index is maintained with an atomic add so that several server processes can write
136+
to it at once without losing each other's entries. A plain `AbstractTokenStore` cannot offer that,
137+
so non_strict requires a store that subclasses `IndexedTokenStore` and implements
138+
`add_index_member` and `list_index_members`. Using non_strict with a plain store raises
139+
`ConfigurationError` at construction.
140+
141+
### Redis IndexedTokenStore example
142+
143+
This extends the `RedisTokenStore` above and backs the index with a Redis hash, one field per
144+
token. Adding a member is a single `HSET`, which is atomic per field and overwrites any member
145+
stored under the same `token_key`.
146+
147+
```python
148+
import json
149+
import time
150+
151+
from auth0_api_python import IndexedTokenStore, TokenIndexMember
152+
153+
154+
class RedisIndexedTokenStore(RedisTokenStore, IndexedTokenStore):
155+
async def add_index_member(self, index_key: str, member: TokenIndexMember) -> None:
156+
await self.redis.hset(index_key, member["token_key"], json.dumps(member))
157+
158+
async def list_index_members(self, index_key: str) -> list[TokenIndexMember]:
159+
raw = await self.redis.hgetall(index_key)
160+
now = int(time.time())
161+
live: list[TokenIndexMember] = []
162+
expired_fields = []
163+
for field, value in raw.items():
164+
member = json.loads(value)
165+
if member["expires_at"] > now:
166+
live.append(member)
167+
else:
168+
expired_fields.append(field)
169+
# Drop expired fields so the hash does not grow without bound as tokens age out.
170+
if expired_fields:
171+
await self.redis.hdel(index_key, *expired_fields)
172+
return live
173+
174+
175+
# Usage
176+
api_client = ApiClient(ApiClientOptions(
177+
domain="your-tenant.auth0.com",
178+
audience="https://mcp-server.example.com",
179+
client_id="<AUTH0_CLIENT_ID>",
180+
client_secret="<AUTH0_CLIENT_SECRET>",
181+
token_store=RedisIndexedTokenStore(redis_client, secret="<YOUR_ENCRYPTION_SECRET>"),
182+
scope_matching="non_strict",
183+
))
184+
```
185+
186+
An index member holds a hashed `token_key`, the granted scopes, and an expiry, never a bearer
187+
token, so it is not encrypted. The tokens themselves stay encrypted under their own keys as
188+
described above.

0 commit comments

Comments
 (0)