auth0-api-python 1.0.0b10__py3-none-any.whl → 1.0.0b11__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,11 +11,25 @@ from .cache import CacheAdapter, InMemoryCache
11
11
  from .config import ApiClientOptions
12
12
  from .errors import (
13
13
  ApiError,
14
+ AuthServerUnavailableError,
14
15
  ConfigurationError,
15
16
  DomainsResolverError,
17
+ GetAccessTokenForConnectionError,
18
+ GetClientCredentialsTokenError,
16
19
  GetTokenByExchangeProfileError,
20
+ MissingOrganizationError,
21
+ OrganizationNotAllowedError,
22
+ VerifyAccessTokenError,
23
+ )
24
+ from .token_store import (
25
+ AbstractTokenStore,
26
+ IndexedTokenStore,
27
+ TokenIndexMember,
28
+ TokenSet,
29
+ VerifiedToken,
17
30
  )
18
31
  from .types import (
32
+ ClientCredentialsTokenResult,
19
33
  DomainsResolver,
20
34
  DomainsResolverContext,
21
35
  OnBehalfOfTokenResult,
@@ -25,14 +39,26 @@ __all__ = [
25
39
  "ApiClient",
26
40
  "ApiClientOptions",
27
41
  "ApiError",
42
+ "AuthServerUnavailableError",
28
43
  "CacheAdapter",
44
+ "ClientCredentialsTokenResult",
29
45
  "ConfigurationError",
30
46
  "DomainsResolver",
31
47
  "DomainsResolverContext",
32
48
  "DomainsResolverError",
49
+ "GetAccessTokenForConnectionError",
50
+ "GetClientCredentialsTokenError",
33
51
  "GetTokenByExchangeProfileError",
52
+ "VerifyAccessTokenError",
34
53
  "get_current_actor",
35
54
  "get_delegation_chain",
36
55
  "InMemoryCache",
56
+ "MissingOrganizationError",
57
+ "AbstractTokenStore",
58
+ "IndexedTokenStore",
59
+ "TokenIndexMember",
60
+ "VerifiedToken",
37
61
  "OnBehalfOfTokenResult",
62
+ "OrganizationNotAllowedError",
63
+ "TokenSet",
38
64
  ]
@@ -0,0 +1 @@
1
+ """Internal machinery for auth0-api-python. Not part of the public API."""
@@ -0,0 +1,9 @@
1
+ """Freshness rule shared by every cache of a token the SDK minted."""
2
+
3
+ # A token this close to expiry is treated as expired, so it is unlikely to lapse before the caller uses it.
4
+ EXPIRY_BUFFER_SECONDS = 30
5
+
6
+
7
+ def is_fresh(expires_at: int, now: int) -> bool:
8
+ """True when a cached token still has more than the expiry buffer left."""
9
+ return expires_at - EXPIRY_BUFFER_SECONDS > now
@@ -0,0 +1,147 @@
1
+ """Cache-key builders and scope helpers for the tokens the SDK mints."""
2
+
3
+ import hashlib
4
+
5
+ from ..utils import get_unverified_payload
6
+
7
+ _FIELD_SEPARATOR = "\x1f"
8
+
9
+
10
+ def _normalized_scopes(scope: str | None) -> str:
11
+ """Sort and dedupe scopes so equivalent scope strings produce the same cache key."""
12
+ if not scope:
13
+ return ""
14
+ return " ".join(sorted(set(scope.split())))
15
+
16
+
17
+ def is_covered_by(requested: str | None, granted: str | None) -> bool:
18
+ """True when the requested scopes are all covered by the granted scopes."""
19
+ granted_set = set(_normalized_scopes(granted).split())
20
+ requested_set = set(_normalized_scopes(requested).split())
21
+ return requested_set.issubset(granted_set)
22
+
23
+
24
+ def session_fingerprint(access_token: str) -> str:
25
+ """
26
+ Derive a session-scoped fingerprint for an access token, to keep concurrent
27
+ sessions' cached tokens from colliding. Not a trust or authorization signal:
28
+ the token is decoded without signature verification.
29
+ """
30
+ try:
31
+ payload = get_unverified_payload(access_token)
32
+ except ValueError:
33
+ return hashlib.sha256(access_token.encode()).hexdigest()
34
+
35
+ sid = payload.get("sid")
36
+ if isinstance(sid, str) and sid:
37
+ return sid
38
+
39
+ jti = payload.get("jti")
40
+ if isinstance(jti, str) and jti:
41
+ return jti
42
+
43
+ return hashlib.sha256(access_token.encode()).hexdigest()
44
+
45
+
46
+ def _obo_identity_fields(
47
+ namespace: str,
48
+ *,
49
+ issuer: str,
50
+ incoming_client_id: str,
51
+ exchange_tenant: str,
52
+ exchange_client_id: str,
53
+ audience: str,
54
+ org_id: str | None,
55
+ session_key: str,
56
+ ) -> list[str]:
57
+ """The fields every OBO cache key shares, namespaced by key kind.
58
+
59
+ Folds in the verified issuer, the incoming token's client, the exchange tenant, and the
60
+ exchange client so a token minted under one of them is never read back under another.
61
+ """
62
+ return [
63
+ namespace,
64
+ issuer,
65
+ incoming_client_id,
66
+ exchange_tenant,
67
+ exchange_client_id,
68
+ audience,
69
+ org_id or "",
70
+ session_key,
71
+ ]
72
+
73
+
74
+ def obo_cache_key(
75
+ *,
76
+ layout: str,
77
+ sub: str,
78
+ issuer: str,
79
+ incoming_client_id: str,
80
+ exchange_tenant: str,
81
+ exchange_client_id: str,
82
+ audience: str,
83
+ org_id: str | None,
84
+ scopes: str | None,
85
+ session_key: str,
86
+ ) -> str:
87
+ """Token cache key for an On Behalf Of exchange.
88
+
89
+ layout is "strict" or "index" so the two scope-matching modes never read each other's tokens.
90
+ The normalized scopes are folded in.
91
+ """
92
+ fields = _obo_identity_fields(
93
+ "obo_token_" + layout,
94
+ issuer=issuer,
95
+ incoming_client_id=incoming_client_id,
96
+ exchange_tenant=exchange_tenant,
97
+ exchange_client_id=exchange_client_id,
98
+ audience=audience,
99
+ org_id=org_id,
100
+ session_key=session_key,
101
+ )
102
+ fields.append(_normalized_scopes(scopes))
103
+ return sub + ":" + hashlib.sha256(_FIELD_SEPARATOR.join(fields).encode()).hexdigest()
104
+
105
+
106
+ def index_cache_key(
107
+ *,
108
+ sub: str,
109
+ issuer: str,
110
+ incoming_client_id: str,
111
+ exchange_tenant: str,
112
+ exchange_client_id: str,
113
+ audience: str,
114
+ org_id: str | None,
115
+ session_key: str,
116
+ ) -> str:
117
+ """Cache key for the OBO token index, scoped to caller, issuer, exchange, audience, org, and session.
118
+
119
+ The "obo_index" namespace keeps it from colliding with either layout's token cache key.
120
+ """
121
+ fields = _obo_identity_fields(
122
+ "obo_index",
123
+ issuer=issuer,
124
+ incoming_client_id=incoming_client_id,
125
+ exchange_tenant=exchange_tenant,
126
+ exchange_client_id=exchange_client_id,
127
+ audience=audience,
128
+ org_id=org_id,
129
+ session_key=session_key,
130
+ )
131
+ return sub + ":" + hashlib.sha256(_FIELD_SEPARATOR.join(fields).encode()).hexdigest()
132
+
133
+
134
+ def m2m_cache_key(*, tenant: str, client_id: str, audience: str, scopes: str | None) -> str:
135
+ """Cache key for a client-credentials (M2M) exchange, scoped to tenant, client, audience, and scopes."""
136
+ fields = _FIELD_SEPARATOR.join(
137
+ ["m2m", tenant, client_id, audience, _normalized_scopes(scopes)]
138
+ )
139
+ return hashlib.sha256(fields.encode()).hexdigest()
140
+
141
+
142
+ def token_vault_cache_key(
143
+ *, sub: str, connection: str, tenant: str, client_id: str, login_hint: str | None = None
144
+ ) -> str:
145
+ """Cache key for a Token Vault exchange, scoped to tenant, client, caller, connection, and login hint."""
146
+ fields = _FIELD_SEPARATOR.join(["token_vault", tenant, client_id, sub, connection, login_hint or ""])
147
+ return hashlib.sha256(fields.encode()).hexdigest()
@@ -0,0 +1,229 @@
1
+ """Caching for On Behalf Of exchanges: cache-key identity, lookup, and write."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import NamedTuple, cast
6
+
7
+ from ..token_store import (
8
+ AbstractTokenStore,
9
+ IndexedTokenStore,
10
+ TokenSet,
11
+ VerifiedToken,
12
+ )
13
+ from ..types import OnBehalfOfTokenResult
14
+ from .cache_expiry import is_fresh
15
+ from .cache_keys import (
16
+ _normalized_scopes,
17
+ index_cache_key,
18
+ is_covered_by,
19
+ obo_cache_key,
20
+ session_fingerprint,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ class _OboIdentity(NamedTuple):
27
+ """The verified fields that identify one caller's OBO cache entries."""
28
+
29
+ sub: str
30
+ issuer: str
31
+ incoming_client_id: str
32
+ org_id: str | None
33
+ session_key: str
34
+
35
+
36
+ class OboCache:
37
+ """Caches On Behalf Of exchange results in a token store, keyed by verified caller identity.
38
+
39
+ scope_matching controls reuse. "strict" reuses a cached token only when its granted scopes
40
+ equal the request. "non_strict" reuses any cached token whose granted scopes cover the
41
+ request, which needs the per-caller index and so an IndexedTokenStore.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ token_store: AbstractTokenStore,
47
+ *,
48
+ exchange_tenant: str,
49
+ exchange_client_id: str,
50
+ scope_matching: str,
51
+ ) -> None:
52
+ self._store = token_store
53
+ self._exchange_tenant = exchange_tenant
54
+ self._exchange_client_id = exchange_client_id
55
+ self._mode = scope_matching
56
+
57
+ def identity(self, verified: VerifiedToken) -> _OboIdentity | None:
58
+ """Build the cache identity from verified claims, or None when they lack a usable subject."""
59
+ claims = verified.claims
60
+ sub = claims.get("sub")
61
+ if not isinstance(sub, str) or not sub:
62
+ logger.warning("Verified token has no usable sub claim, skipping cache")
63
+ return None
64
+ issuer = claims.get("iss")
65
+ if not isinstance(issuer, str) or not issuer:
66
+ logger.warning("Verified token has no iss claim, skipping cache")
67
+ return None
68
+ org_id = claims.get("org_id") if isinstance(claims.get("org_id"), str) else None
69
+ incoming = claims.get("azp") or claims.get("client_id")
70
+ incoming_client_id = incoming if isinstance(incoming, str) else ""
71
+ return _OboIdentity(
72
+ sub=sub,
73
+ issuer=issuer,
74
+ incoming_client_id=incoming_client_id,
75
+ org_id=org_id,
76
+ session_key=session_fingerprint(verified.access_token),
77
+ )
78
+
79
+ async def lookup(
80
+ self, identity: _OboIdentity, audience: str, scope: str | None
81
+ ) -> OnBehalfOfTokenResult | None:
82
+ """Return a reusable cached token for this request, or None on a miss."""
83
+ if self._mode == "non_strict":
84
+ return await self._index_lookup(identity, audience, scope)
85
+ return await self._strict_lookup(identity, audience, scope)
86
+
87
+ async def write(
88
+ self,
89
+ identity: _OboIdentity,
90
+ audience: str,
91
+ scope: str | None,
92
+ obo_result: OnBehalfOfTokenResult,
93
+ ) -> None:
94
+ """Store a freshly exchanged token under its granted scopes."""
95
+ # When Auth0 does not echo the granted scope, the requested scope is the best label available.
96
+ granted = _normalized_scopes(obo_result["scope"] if "scope" in obo_result else scope)
97
+ entry: TokenSet = {
98
+ "access_token": obo_result["access_token"],
99
+ "expires_at": obo_result["expires_at"],
100
+ "granted_scopes": granted,
101
+ }
102
+ try:
103
+ if self._mode == "non_strict":
104
+ token_key = self._token_key(identity, audience, "index", granted)
105
+ await self._store.set(token_key, entry)
106
+ await cast(IndexedTokenStore, self._store).add_index_member(
107
+ self._index_key(identity, audience),
108
+ {"token_key": token_key, "granted_scopes": granted, "expires_at": obo_result["expires_at"]},
109
+ )
110
+ else:
111
+ await self._store.set(self._token_key(identity, audience, "strict", granted), entry)
112
+ except Exception as exc:
113
+ logger.warning("Token store write failed, token still returned: %s", exc)
114
+
115
+ # ===== Private methods =====
116
+
117
+ def _token_key(self, identity: _OboIdentity, audience: str, layout: str, scopes: str | None) -> str:
118
+ return obo_cache_key(
119
+ layout=layout,
120
+ sub=identity.sub,
121
+ issuer=identity.issuer,
122
+ incoming_client_id=identity.incoming_client_id,
123
+ exchange_tenant=self._exchange_tenant,
124
+ exchange_client_id=self._exchange_client_id,
125
+ audience=audience,
126
+ org_id=identity.org_id,
127
+ scopes=scopes,
128
+ session_key=identity.session_key,
129
+ )
130
+
131
+ def _index_key(self, identity: _OboIdentity, audience: str) -> str:
132
+ return index_cache_key(
133
+ sub=identity.sub,
134
+ issuer=identity.issuer,
135
+ incoming_client_id=identity.incoming_client_id,
136
+ exchange_tenant=self._exchange_tenant,
137
+ exchange_client_id=self._exchange_client_id,
138
+ audience=audience,
139
+ org_id=identity.org_id,
140
+ session_key=identity.session_key,
141
+ )
142
+
143
+ @staticmethod
144
+ def _warn_read_failed(exc: Exception) -> None:
145
+ # A bad secret or tampered entry fails inside the store's decrypt, so it surfaces here too.
146
+ logger.warning("Token store read failed or entry was unreadable, treating as cache miss: %s", exc)
147
+
148
+ @staticmethod
149
+ def _well_formed_member(member: object) -> bool:
150
+ """True when an index member has the fields the lookup reads, so a malformed one is a miss."""
151
+ return (
152
+ isinstance(member, dict)
153
+ and isinstance(member.get("token_key"), str)
154
+ and isinstance(member.get("granted_scopes"), str)
155
+ and isinstance(member.get("expires_at"), int)
156
+ )
157
+
158
+ @staticmethod
159
+ def _usable_entry(cached: TokenSet | None, now: int) -> bool:
160
+ if cached is None:
161
+ return False
162
+ if "access_token" not in cached or "expires_at" not in cached:
163
+ logger.warning("Token store returned a malformed entry, treating as cache miss")
164
+ return False
165
+ return is_fresh(cached["expires_at"], now)
166
+
167
+ @staticmethod
168
+ def _hit(cached: TokenSet, now: int) -> OnBehalfOfTokenResult:
169
+ result: OnBehalfOfTokenResult = {
170
+ "access_token": cached["access_token"],
171
+ "expires_in": cached["expires_at"] - now,
172
+ "expires_at": cached["expires_at"],
173
+ }
174
+ granted = cached.get("granted_scopes")
175
+ if granted is not None:
176
+ result["scope"] = granted
177
+ return result
178
+
179
+ async def _strict_lookup(
180
+ self, identity: _OboIdentity, audience: str, scope: str | None
181
+ ) -> OnBehalfOfTokenResult | None:
182
+ now = int(time.time())
183
+ try:
184
+ cached = await self._store.get(self._token_key(identity, audience, "strict", scope))
185
+ except Exception as exc:
186
+ self._warn_read_failed(exc)
187
+ return None
188
+ if not self._usable_entry(cached, now):
189
+ return None
190
+ # Entries are stored under their granted-scope key, so confirm the grant matches exactly.
191
+ if _normalized_scopes(cached.get("granted_scopes")) != _normalized_scopes(scope):
192
+ return None
193
+ return self._hit(cached, now)
194
+
195
+ async def _index_lookup(
196
+ self, identity: _OboIdentity, audience: str, scope: str | None
197
+ ) -> OnBehalfOfTokenResult | None:
198
+ # An unscoped request is covered by every cached token, so never reuse for one.
199
+ requested_scopes = _normalized_scopes(scope)
200
+ if not requested_scopes:
201
+ return None
202
+ now = int(time.time())
203
+ indexed = cast(IndexedTokenStore, self._store)
204
+ try:
205
+ members = await indexed.list_index_members(self._index_key(identity, audience))
206
+ candidates = [
207
+ m for m in members
208
+ if self._well_formed_member(m) and is_fresh(m["expires_at"], now) and is_covered_by(scope, m["granted_scopes"])
209
+ ]
210
+ except Exception as exc:
211
+ self._warn_read_failed(exc)
212
+ return None
213
+ requested = set(requested_scopes.split())
214
+ # Prefer an exact grant, then the covering grant with the fewest extra scopes.
215
+ candidates.sort(key=lambda m: len(set(_normalized_scopes(m["granted_scopes"]).split()) - requested))
216
+ for member in candidates:
217
+ # A failing store would fail every remaining read too, so stop at the first one.
218
+ try:
219
+ cached = await self._store.get(member["token_key"])
220
+ except Exception as exc:
221
+ self._warn_read_failed(exc)
222
+ return None
223
+ if not self._usable_entry(cached, now):
224
+ continue
225
+ # Trust the token value's own grant, not only the index label.
226
+ if not is_covered_by(scope, cached.get("granted_scopes")):
227
+ continue
228
+ return self._hit(cached, now)
229
+ return None
@@ -0,0 +1,72 @@
1
+ """Caching for Token Vault (federated connection) exchanges: lookup and write."""
2
+
3
+ import logging
4
+ import time
5
+ from typing import Any
6
+
7
+ from ..token_store import AbstractTokenStore, TokenSet
8
+ from .cache_expiry import is_fresh
9
+ from .cache_keys import _normalized_scopes, token_vault_cache_key
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class TokenVaultCache:
15
+ """Caches Token Vault exchange results in a token store, keyed by caller and connection.
16
+
17
+ ApiClient builds one TokenVaultCache when a token_store is configured and delegates
18
+ every cache decision to it.
19
+ """
20
+
21
+ def __init__(self, token_store: AbstractTokenStore) -> None:
22
+ self._store = token_store
23
+
24
+ def cache_key(
25
+ self, tenant: str, client_id: str, sub: Any, connection: str, login_hint: str | None = None
26
+ ) -> str | None:
27
+ """Return the cache key, or None when sub is not a usable string (cache skipped with a warning)."""
28
+ if not isinstance(sub, str) or not sub:
29
+ logger.warning("Access token has no usable sub claim, skipping Token Vault cache")
30
+ return None
31
+ return token_vault_cache_key(
32
+ sub=sub, connection=connection, tenant=tenant, client_id=client_id, login_hint=login_hint
33
+ )
34
+
35
+ async def lookup(self, key: str) -> dict | None:
36
+ """Return a live cached token or None. Miss cases: absent, malformed, expired."""
37
+ cached: TokenSet | None = None
38
+ try:
39
+ cached = await self._store.get(key)
40
+ except Exception as exc:
41
+ logger.warning("Token store read failed, treating as cache miss: %s", exc)
42
+ return None
43
+
44
+ if cached is None:
45
+ return None
46
+ access_token = cached.get("access_token")
47
+ expires_at = cached.get("expires_at")
48
+ if not isinstance(access_token, str) or not access_token or not isinstance(expires_at, int):
49
+ logger.warning("Token store returned a malformed entry, treating as cache miss")
50
+ return None
51
+ now = int(time.time())
52
+ if not is_fresh(expires_at, now):
53
+ return None
54
+
55
+ return {
56
+ "access_token": access_token,
57
+ "expires_in": expires_at - now,
58
+ "expires_at": expires_at,
59
+ "scope": cached.get("granted_scopes") or "",
60
+ }
61
+
62
+ async def write(self, key: str, result: dict) -> None:
63
+ """Store a freshly exchanged connection token. Failures are logged and swallowed."""
64
+ entry: TokenSet = {
65
+ "access_token": result["access_token"],
66
+ "expires_at": result["expires_at"],
67
+ "granted_scopes": _normalized_scopes(result.get("scope")),
68
+ }
69
+ try:
70
+ await self._store.set(key, entry)
71
+ except Exception as exc:
72
+ logger.warning("Token store write failed, token still returned: %s", exc)
auth0_api_python/act.py CHANGED
@@ -3,14 +3,14 @@ Helpers for working with the `act` claim on verified access token claims.
3
3
  """
4
4
 
5
5
  from collections.abc import Mapping
6
- from typing import Any, Optional
6
+ from typing import Any
7
7
 
8
8
  from .errors import VerifyAccessTokenError
9
9
 
10
10
  INVALID_ACT_CLAIM_MESSAGE = "Invalid act claim"
11
11
 
12
12
 
13
- def get_current_actor(claims: Mapping[str, Any]) -> Optional[str]:
13
+ def get_current_actor(claims: Mapping[str, Any]) -> str | None:
14
14
  """
15
15
  Return the current actor from the outermost `act.sub`, if present.
16
16