Skip to content

feat: add server-side token storage with OBO caching - #136

Open
kishore7snehil wants to merge 5 commits into
feat/org-policy-enforcementfrom
feat/obo-token-storage
Open

kishore7snehil wants to merge 5 commits into
feat/org-policy-enforcementfrom
feat/obo-token-storage

Conversation

@kishore7snehil

Copy link
Copy Markdown
Contributor

📋 Changes

This PR adds pluggable server-side token storage to auth0-api-python and uses it to cache On Behalf Of exchanges. When a token store is configured, get_token_on_behalf_of() (already in main from a prior PR) reuses previously exchanged tokens instead of hitting the token endpoint on every call.

✨ Features

  • Pluggable Token Store: New AbstractTokenStore ABC for server-side token persistence, with JWE-at-rest encrypt and decrypt helpers built in. Subclasses implement get, set, and delete.
  • Scope-Indexed Store: New IndexedTokenStore variant that maintains a scope index, required for non-strict scope matching.
  • OBO Response Caching: get_token_on_behalf_of() now caches exchanged tokens when token_store is set.
  • Scope Matching Modes: New scope_matching option. "strict" reuses a cached token only on an exact scope match. "non_strict" reuses a cached token when its granted scopes cover the request, and requires an IndexedTokenStore.
  • At-Rest Encryption: New encryption.py module providing the JWE helpers used by the store.
  • Type Safety: New TokenSet and TokenIndexMember TypedDicts and a VerifiedToken dataclass.

🔧 API Changes

  • Added token_store (AbstractTokenStore, default None) to ApiClientOptions. When set, OBO exchanges are cached.
  • Added scope_matching (str, default "strict") to ApiClientOptions. Using "non_strict" without an IndexedTokenStore raises ConfigurationError.
  • New classes: AbstractTokenStore, IndexedTokenStore
  • New types: TokenSet (TypedDict), TokenIndexMember (TypedDict), VerifiedToken (dataclass)
  • New error: TokenStoreError (status 500), raised when the configured store backend fails
  • Newly exported at top level: GetTokenByExchangeProfileError

📖 Documentation

  • Added docs/TokenStorage.md covering the AbstractTokenStore contract, a Redis implementation example, and the scope matching modes
  • Updated README.md with a token storage section
  • Updated EXAMPLES.md with token storage and caching examples

🧪 Testing

  • This change adds test coverage
  • This change has been tested on the latest version of the platform/language

Contributor Checklist

Comment thread src/auth0_api_python/api_client.py Fixed
Comment thread src/auth0_api_python/api_client.py Fixed
Comment thread src/auth0_api_python/token_store.py Fixed
Comment thread src/auth0_api_python/token_store.py Fixed
Comment thread src/auth0_api_python/token_store.py Fixed
Comment thread src/auth0_api_python/token_store.py Fixed
Comment thread src/auth0_api_python/token_store.py Fixed
@kishore7snehil
kishore7snehil marked this pull request as ready for review October 6, 2026 06:46
@kishore7snehil
kishore7snehil requested a review from a team as a code owner October 6, 2026 06:46
return "domains_resolver_error"


class TokenStoreError(BaseAuthError):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

TokenStoreError is in __all__ so it is a public type, but it is never actually raised anywhere.

In all the best effort paths we build it and then only log its .cause, so a consumer who writes except TokenStoreError will never catch anything from this SDK. Can we either keep it internal (drop it from __all__) or actually raise it? Also cause: Exception = None on line 182 should be Optional[Exception].

Comment thread EXAMPLES.md

claims = await api_client.verify_access_token(access_token=incoming_access_token)

result = await api_client.get_token_on_behalf_of(

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We call verify_access_token just above and then call get_token_on_behalf_of without passing verified=, so claims is unused and, since a token_store is set, the token gets verified a second time inside.

Can we pass verified=VerifiedToken(access_token=incoming_access_token, claims=claims) here? That also shows the new verified entry point, which is the main thing a caller who already verified would use.

if not isinstance(sub, str) or not sub:
logging.warning("Verified token has no usable sub claim, skipping cache")
return None
issuer = claims.get("iss")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Just noting here, when a caller passes verified but the claims do not carry both sub and iss, we skip caching and only log a warning. The verified docstring does not mention that iss and sub must be present, so someone passing a minimal claims mapping will silently get no caching.

Can we document the required claim keys, and maybe add a test for the iss missing case? Only the sub missing case is covered right now.

Comment thread docs/TokenStorage.md

async def set(self, key: str, value: TokenSet) -> None:
encrypted = self.encrypt(key, value)
ttl = max(value["expires_at"] - int(time.time()), 0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small bug in the reference Redis store. ttl can come out as 0 here, and redis.set(..., ex=0) is rejected by Redis, so a copy paste of this store will raise on writes of a token whose remaining life rounds to zero.

Writes are best effort so the call still succeeds, but caching quietly breaks for those entries. Can we clamp to max(..., 1)?

)
if (
options.scope_matching == "non_strict"
and options.token_store is not None

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 guard has and options.token_store is not None, so if someone sets scope_matching='non_strict' but forgets to pass a token_store, we neither raise nor cache, it just silently does nothing.

Can we either raise ConfigurationError for that case as well, or at least warn that caching will be inert?

claims = verified.claims
sub = claims.get("sub")
if not isinstance(sub, str) or not sub:
logging.warning("Verified token has no usable sub claim, skipping cache")

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Small one, all these warnings go through logging.warning(...) on the root logger, so they cannot be filtered by name.

Under a non_strict store outage the per member read path can also emit one warning per index member per lookup.

Can we switch to logging.getLogger(__name__) and maybe de dup the per member warnings?

"""
try:
payload = get_unverified_payload(access_token)
except ValueError:

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:

This only catches ValueError. If the token payload decodes to valid JSON that is not an object, payload.get("sid") raises AttributeError, which is not caught here, so the intended sha256 fallback gets skipped. Not reachable in the normal verified token path, but an isinstance(payload, dict) check or a wider guard would be safer.

self, identity: _OboIdentity, audience: str, scope: Optional[str]
) -> Optional[OnBehalfOfTokenResult]:
# An unscoped request is covered by every cached token, so never reuse for one.
if not scope:

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:

if not scope is false for a value like " ", which then normalizes to an empty set and gets treated as covered by every cached token. Very unlikely input, but normalizing before this guard would close it.

return cached["expires_at"] > now

@staticmethod
def _hit(cached: TokenSet) -> OnBehalfOfTokenResult:

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:
int(time.time()) is computed here separately from the usable entry check, so a token that was valid at the check can still come out with expires_in = 0 at a second boundary.

Can we compute now once and reuse it?

"""Base class for external token stores with built-in JWE encrypt and decrypt helpers."""

def __init__(self, *, secret: str) -> None:
self._secret = secret

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:

Any secret is accepted here including an empty string. Might be worth a minimum length check, or at least documenting the expectation.

I didn't double check if we handle this in other SDKs. PTAL.

@kishore7snehil
kishore7snehil force-pushed the feat/org-policy-enforcement branch from 8f87351 to f4b287d Compare October 8, 2026 04:07
@kishore7snehil
kishore7snehil force-pushed the feat/obo-token-storage branch from 42680cd to d57457d Compare October 8, 2026 04:07
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
@kishore7snehil
kishore7snehil force-pushed the feat/org-policy-enforcement branch from f4b287d to ae99659 Compare October 8, 2026 04:22
@kishore7snehil
kishore7snehil force-pushed the feat/obo-token-storage branch from d57457d to 75faa2d Compare October 8, 2026 04:22

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants