|
| 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