auth0-api-python 1.0.0b6__py3-none-any.whl → 1.0.0b8__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.
@@ -6,12 +6,25 @@ in server-side APIs, using Authlib for OIDC discovery and JWKS fetching.
6
6
  """
7
7
 
8
8
  from .api_client import ApiClient
9
+ from .cache import CacheAdapter, InMemoryCache
9
10
  from .config import ApiClientOptions
10
- from .errors import ApiError, GetTokenByExchangeProfileError
11
+ from .errors import (
12
+ ApiError,
13
+ ConfigurationError,
14
+ DomainsResolverError,
15
+ GetTokenByExchangeProfileError,
16
+ )
17
+ from .types import DomainsResolver, DomainsResolverContext
11
18
 
12
19
  __all__ = [
13
20
  "ApiClient",
14
21
  "ApiClientOptions",
15
22
  "ApiError",
16
- "GetTokenByExchangeProfileError"
23
+ "CacheAdapter",
24
+ "ConfigurationError",
25
+ "DomainsResolver",
26
+ "DomainsResolverContext",
27
+ "DomainsResolverError",
28
+ "GetTokenByExchangeProfileError",
29
+ "InMemoryCache",
17
30
  ]
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import time
2
3
  from collections.abc import Mapping, Sequence
3
4
  from typing import Any, Optional, Union
@@ -5,10 +6,13 @@ from typing import Any, Optional, Union
5
6
  import httpx
6
7
  from authlib.jose import JsonWebKey, JsonWebToken
7
8
 
9
+ from .cache import InMemoryCache
8
10
  from .config import ApiClientOptions
9
11
  from .errors import (
10
12
  ApiError,
11
13
  BaseAuthError,
14
+ ConfigurationError,
15
+ DomainsResolverError,
12
16
  GetAccessTokenForConnectionError,
13
17
  GetTokenByExchangeProfileError,
14
18
  InvalidAuthSchemeError,
@@ -22,6 +26,8 @@ from .utils import (
22
26
  fetch_jwks,
23
27
  fetch_oidc_metadata,
24
28
  get_unverified_header,
29
+ get_unverified_payload,
30
+ normalize_domain,
25
31
  normalize_url_for_htu,
26
32
  sha256_base64url,
27
33
  )
@@ -48,14 +54,62 @@ class ApiClient:
48
54
  """
49
55
 
50
56
  def __init__(self, options: ApiClientOptions):
51
- if not options.domain:
52
- raise MissingRequiredArgumentError("domain")
57
+ # Validate audience is always required
53
58
  if not options.audience:
54
59
  raise MissingRequiredArgumentError("audience")
55
60
 
61
+ # Validate domains parameter if provided
62
+ if options.domains is not None:
63
+ if isinstance(options.domains, list):
64
+ # Static list validation
65
+ if len(options.domains) == 0:
66
+ raise ConfigurationError("domains list cannot be empty")
67
+ if not all(isinstance(d, str) and d.strip() for d in options.domains):
68
+ raise ConfigurationError(
69
+ "domains list must contain only non-empty strings"
70
+ )
71
+ # Normalize and store domains
72
+ self._allowed_domains = [normalize_domain(d) for d in options.domains]
73
+ elif callable(options.domains):
74
+ # Dynamic resolver - store the function
75
+ self._allowed_domains = options.domains
76
+ else:
77
+ raise ConfigurationError(
78
+ "domains must be either a list of domain strings or a callable resolver function"
79
+ )
80
+ else:
81
+ # Single domain mode
82
+ self._allowed_domains = None
83
+
84
+ # Validate domain/domains configuration
85
+ if not options.domain and not options.domains:
86
+ raise ConfigurationError(
87
+ "Must provide either 'domain' or 'domains' parameter. "
88
+ "Use 'domain' for single-domain mode, 'domains' for multi-domain support."
89
+ )
90
+
91
+ # Validate that domain is set when client_id is configured
92
+ if options.client_id and not options.domain:
93
+ raise ConfigurationError(
94
+ "The 'domain' parameter is required when 'client_id' is configured."
95
+ )
56
96
  self.options = options
57
- self._metadata: Optional[dict[str, Any]] = None
58
- self._jwks_data: Optional[dict[str, Any]] = None
97
+
98
+ # Validate cache configuration
99
+ if not isinstance(options.cache_ttl_seconds, (int, float)) or options.cache_ttl_seconds < 0:
100
+ raise ConfigurationError("cache_ttl_seconds must be a non-negative number")
101
+
102
+ if not isinstance(options.cache_max_entries, int) or options.cache_max_entries < 2:
103
+ raise ConfigurationError("cache_max_entries must be an integer greater than 1")
104
+
105
+ if options.cache_adapter:
106
+ self._discovery_cache = options.cache_adapter
107
+ self._jwks_cache = options.cache_adapter
108
+ else:
109
+ self._discovery_cache = InMemoryCache(max_entries=options.cache_max_entries)
110
+ self._jwks_cache = InMemoryCache(max_entries=options.cache_max_entries)
111
+
112
+ self._cache_ttl = options.cache_ttl_seconds
59
113
 
60
114
  self._jwt = JsonWebToken(["RS256"])
61
115
 
@@ -66,6 +120,92 @@ class ApiClient:
66
120
  """Check if DPoP authentication is required."""
67
121
  return getattr(self.options, "dpop_required", False)
68
122
 
123
+ async def _resolve_allowed_domains(
124
+ self,
125
+ unverified_iss: str,
126
+ request_url: Optional[str] = None,
127
+ request_headers: Optional[dict] = None
128
+ ) -> Optional[list[str]]:
129
+ """
130
+ Resolve and validate allowed domains for the given issuer.
131
+
132
+ Handles three modes:
133
+ 1. Static list: Returns normalized list, validates issuer against it
134
+ 2. Dynamic resolver: Invokes resolver function, validates issuer against result
135
+ 3. Single domain: Returns None (backward compatibility, uses domain)
136
+
137
+ Args:
138
+ unverified_iss: The issuer claim from the token (not yet verified)
139
+ request_url: Optional request URL for dynamic resolvers
140
+ request_headers: Optional request headers for dynamic resolvers
141
+
142
+ Returns:
143
+ List of normalized allowed domain strings
144
+
145
+ Raises:
146
+ DomainsResolverError: If resolver invocation fails
147
+ VerifyAccessTokenError: If issuer is not in allowed domains
148
+ """
149
+ # Single domain mode
150
+ if self._allowed_domains is None:
151
+ return None
152
+
153
+ # Static list mode
154
+ if isinstance(self._allowed_domains, list):
155
+ allowed_domains = self._allowed_domains
156
+ # Dynamic resolver mode
157
+ elif callable(self._allowed_domains):
158
+ # Build resolver context
159
+ context = {
160
+ 'request_url': request_url,
161
+ 'request_headers': request_headers,
162
+ 'unverified_iss': unverified_iss
163
+ }
164
+
165
+ # Invoke resolver (supports both sync and async resolvers)
166
+ try:
167
+ result = self._allowed_domains(context)
168
+ if asyncio.iscoroutine(result) or asyncio.isfuture(result):
169
+ result = await result
170
+ except Exception as e:
171
+ raise DomainsResolverError(
172
+ f"Domains resolver function failed: {str(e)}"
173
+ ) from e
174
+
175
+ # Validate resolver result
176
+ if not isinstance(result, list):
177
+ raise DomainsResolverError(
178
+ "Domains resolver must return a list"
179
+ )
180
+
181
+ if len(result) == 0:
182
+ raise DomainsResolverError(
183
+ "Domains resolver returned an empty list"
184
+ )
185
+
186
+ if not all(isinstance(d, str) and d.strip() for d in result):
187
+ raise DomainsResolverError(
188
+ "Domains resolver must return a list of non-empty strings"
189
+ )
190
+
191
+ # Normalize domains from resolver
192
+ try:
193
+ allowed_domains = [normalize_domain(d) for d in result]
194
+ except ValueError as e:
195
+ raise DomainsResolverError(
196
+ f"Domains resolver returned invalid domain: {str(e)}"
197
+ ) from e
198
+ else:
199
+ # Should never happen due to __init__ validation
200
+ raise ConfigurationError("Invalid _allowed_domains type")
201
+
202
+ # Validate issuer is in allowed domains
203
+ if unverified_iss not in allowed_domains:
204
+ raise VerifyAccessTokenError(
205
+ "Token issuer is not in the list of allowed domains"
206
+ )
207
+
208
+ return allowed_domains
69
209
 
70
210
  async def verify_request(
71
211
  self,
@@ -89,7 +229,7 @@ class ApiClient:
89
229
  - "authorization": The Authorization header value (required)
90
230
  - "dpop": The DPoP proof header value (required for DPoP)
91
231
  http_method: The HTTP method (required for DPoP)
92
- http_url: The HTTP URL (required for DPoP)
232
+ http_url: The HTTP URL (required for DPoP, also used for MCD resolver context)
93
233
 
94
234
  Returns:
95
235
  The decoded access token claims
@@ -171,7 +311,11 @@ class ApiClient:
171
311
  )
172
312
 
173
313
  try:
174
- access_token_claims = await self.verify_access_token(token)
314
+ access_token_claims = await self.verify_access_token(
315
+ token,
316
+ request_url=http_url,
317
+ request_headers=headers
318
+ )
175
319
  except VerifyAccessTokenError as e:
176
320
  raise self._prepare_error(e, auth_scheme=scheme)
177
321
 
@@ -219,7 +363,11 @@ class ApiClient:
219
363
 
220
364
  if scheme == "bearer":
221
365
  try:
222
- claims = await self.verify_access_token(token)
366
+ claims = await self.verify_access_token(
367
+ token,
368
+ request_url=http_url,
369
+ request_headers=headers
370
+ )
223
371
  if claims.get("cnf") and isinstance(claims["cnf"], dict) and claims["cnf"].get("jkt"):
224
372
  if self.options.dpop_enabled:
225
373
  raise self._prepare_error(
@@ -245,6 +393,8 @@ class ApiClient:
245
393
  async def verify_access_token(
246
394
  self,
247
395
  access_token: str,
396
+ request_url: Optional[str] = None,
397
+ request_headers: Optional[dict] = None,
248
398
  required_claims: Optional[list[str]] = None
249
399
  ) -> dict[str, Any]:
250
400
  """
@@ -255,25 +405,113 @@ class ApiClient:
255
405
  - Checks standard claims: 'iss', 'aud', 'exp', 'iat'
256
406
  - Checks extra required claims if 'required_claims' is provided.
257
407
 
408
+ Args:
409
+ access_token: The JWT access token to verify
410
+ request_url: Optional request URL for dynamic domain resolvers
411
+ request_headers: Optional request headers dict for dynamic domain resolvers
412
+ required_claims: Optional list of additional claim names that must be present
413
+
258
414
  Returns:
259
415
  The decoded token claims if valid.
260
416
 
261
417
  Raises:
262
418
  MissingRequiredArgumentError: If no token is provided.
263
419
  VerifyAccessTokenError: If verification fails (signature, claims mismatch, etc.).
420
+ DomainsResolverError: If domains resolver function fails.
264
421
  """
265
422
  if not access_token:
266
423
  raise MissingRequiredArgumentError("access_token")
267
424
 
268
425
  required_claims = required_claims or []
269
426
 
427
+ # Extract header and payload without signature verification
270
428
  try:
271
429
  header = get_unverified_header(access_token)
272
- kid = header["kid"]
273
430
  except Exception as e:
274
431
  raise VerifyAccessTokenError(f"Failed to parse token header: {str(e)}") from e
275
432
 
276
- jwks_data = await self._load_jwks()
433
+ # Reject symmetric algorithms
434
+ alg = header.get('alg', '')
435
+ if alg.startswith('HS'):
436
+ raise VerifyAccessTokenError(
437
+ f"Symmetric algorithm '{alg}' is not supported. "
438
+ "Only asymmetric algorithms (e.g., RS256) are allowed."
439
+ )
440
+
441
+ # Extract and validate issuer claim (before network calls)
442
+ try:
443
+ unverified_payload = get_unverified_payload(access_token)
444
+ except Exception as e:
445
+ raise VerifyAccessTokenError(f"Failed to parse token payload: {str(e)}") from e
446
+
447
+ unverified_iss = unverified_payload.get('iss')
448
+ if not unverified_iss:
449
+ raise VerifyAccessTokenError("Token missing 'iss' claim")
450
+
451
+ # Normalize issuer for validation
452
+ try:
453
+ normalized_iss = normalize_domain(unverified_iss)
454
+ except ValueError as e:
455
+ raise VerifyAccessTokenError(f"Invalid token issuer format: {str(e)}") from e
456
+
457
+ # Validate issuer against allowed domains (MCD)
458
+ if self._allowed_domains is not None:
459
+ await self._resolve_allowed_domains(
460
+ normalized_iss,
461
+ request_url=request_url,
462
+ request_headers=request_headers
463
+ )
464
+
465
+ # Fetch OIDC discovery metadata
466
+ try:
467
+ if self._allowed_domains is not None:
468
+ metadata = await self._discover(issuer=normalized_iss)
469
+ else:
470
+ metadata = await self._discover()
471
+ except VerifyAccessTokenError:
472
+ raise
473
+ except Exception as e:
474
+ raise VerifyAccessTokenError(
475
+ f"Failed to fetch OIDC discovery metadata: {str(e)}"
476
+ ) from e
477
+
478
+ # First issuer validation: Prevent issuer confusion attacks
479
+ discovery_issuer = metadata.get("issuer")
480
+ if not discovery_issuer:
481
+ raise VerifyAccessTokenError("Discovery metadata missing 'issuer' field")
482
+
483
+ # Normalize discovery issuer for comparison
484
+ try:
485
+ normalized_discovery_issuer = normalize_domain(discovery_issuer)
486
+ except ValueError as e:
487
+ raise VerifyAccessTokenError(f"Invalid discovery issuer format: {str(e)}") from e
488
+
489
+ if normalized_iss != normalized_discovery_issuer:
490
+ raise VerifyAccessTokenError(
491
+ "Token issuer does not match the discovery issuer"
492
+ )
493
+
494
+ # Extract JWKS URI from discovery metadata
495
+ jwks_uri = metadata.get("jwks_uri")
496
+ if not jwks_uri:
497
+ raise VerifyAccessTokenError("Discovery metadata missing 'jwks_uri' field")
498
+
499
+ # Fetch JWKS from discovery's jwks_uri
500
+ try:
501
+ jwks_data = await self._fetch_jwks(jwks_uri)
502
+ except VerifyAccessTokenError:
503
+ raise
504
+ except Exception as e:
505
+ raise VerifyAccessTokenError(
506
+ f"Failed to fetch JWKS: {str(e)}"
507
+ ) from e
508
+
509
+ # Extract kid for JWKS lookup
510
+ kid = header.get("kid")
511
+ if not kid:
512
+ raise VerifyAccessTokenError("Token header missing 'kid' claim")
513
+
514
+ # Find matching key
277
515
  matching_key_dict = None
278
516
  for key_dict in jwks_data["keys"]:
279
517
  if key_dict.get("kid") == kid:
@@ -281,8 +519,9 @@ class ApiClient:
281
519
  break
282
520
 
283
521
  if not matching_key_dict:
284
- raise VerifyAccessTokenError(f"No matching key found for kid: {kid}")
522
+ raise VerifyAccessTokenError("No matching key found in JWKS")
285
523
 
524
+ # Import public key and verify signature
286
525
  public_key = JsonWebKey.import_key(matching_key_dict)
287
526
 
288
527
  if isinstance(access_token, str) and access_token.startswith("b'"):
@@ -292,11 +531,11 @@ class ApiClient:
292
531
  except Exception as e:
293
532
  raise VerifyAccessTokenError(f"Signature verification failed: {str(e)}") from e
294
533
 
295
- metadata = await self._discover()
296
- issuer = metadata["issuer"]
297
-
298
- if claims.get("iss") != issuer:
299
- raise VerifyAccessTokenError("Issuer mismatch")
534
+ # Second issuer validation: Ensure verified token wasn't tampered
535
+ if claims.get("iss") != discovery_issuer:
536
+ raise VerifyAccessTokenError(
537
+ "Verified Token issuer does not match the discovery issuer"
538
+ )
300
539
 
301
540
  expected_aud = self.options.audience
302
541
  actual_aud = claims.get("aud")
@@ -767,25 +1006,73 @@ class ApiClient:
767
1006
  else:
768
1007
  params[key] = str(v)
769
1008
 
770
- async def _discover(self) -> dict[str, Any]:
771
- """Lazy-load OIDC discovery metadata."""
772
- if self._metadata is None:
773
- self._metadata = await fetch_oidc_metadata(
774
- domain=self.options.domain,
775
- custom_fetch=self.options.custom_fetch
776
- )
777
- return self._metadata
778
-
779
- async def _load_jwks(self) -> dict[str, Any]:
780
- """Fetches and caches JWKS data from the OIDC metadata."""
781
- if self._jwks_data is None:
782
- metadata = await self._discover()
783
- jwks_uri = metadata["jwks_uri"]
784
- self._jwks_data = await fetch_jwks(
785
- jwks_uri=jwks_uri,
786
- custom_fetch=self.options.custom_fetch
787
- )
788
- return self._jwks_data
1009
+ async def _discover(self, issuer: Optional[str] = None) -> dict[str, Any]:
1010
+ """
1011
+ Lazy-load OIDC discovery metadata.
1012
+
1013
+ Args:
1014
+ issuer: Optional issuer URL to fetch discovery from (MCD mode).
1015
+ If provided, extracts domain from issuer URL.
1016
+ If None, uses configured domain.
1017
+
1018
+ Returns:
1019
+ OIDC discovery metadata dictionary
1020
+ """
1021
+ if issuer:
1022
+ cache_key = issuer # Already normalized by caller
1023
+ domain = issuer.replace('https://', '').replace('http://', '').rstrip('/')
1024
+ else:
1025
+ domain = self.options.domain
1026
+ cache_key = normalize_domain(f"https://{domain}")
1027
+
1028
+ cached = self._discovery_cache.get(cache_key)
1029
+ if cached:
1030
+ return cached
1031
+
1032
+ metadata, max_age = await fetch_oidc_metadata(
1033
+ domain=domain,
1034
+ custom_fetch=self.options.custom_fetch
1035
+ )
1036
+
1037
+ effective_ttl = self._cache_ttl
1038
+ if max_age is not None and self._cache_ttl is not None:
1039
+ effective_ttl = min(max_age, self._cache_ttl)
1040
+ elif max_age is not None:
1041
+ effective_ttl = max_age
1042
+
1043
+ self._discovery_cache.set(cache_key, metadata, ttl_seconds=effective_ttl)
1044
+ return metadata
1045
+
1046
+ async def _fetch_jwks(self, jwks_uri: str) -> dict[str, Any]:
1047
+ """
1048
+ Fetch JWKS with per-URI caching.
1049
+
1050
+ Args:
1051
+ jwks_uri: The JWKS URI to fetch from
1052
+
1053
+ Returns:
1054
+ JWKS data dictionary
1055
+
1056
+ """
1057
+ cache_key = jwks_uri
1058
+
1059
+ cached = self._jwks_cache.get(cache_key)
1060
+ if cached:
1061
+ return cached
1062
+
1063
+ jwks_data, max_age = await fetch_jwks(
1064
+ jwks_uri=jwks_uri,
1065
+ custom_fetch=self.options.custom_fetch
1066
+ )
1067
+
1068
+ effective_ttl = self._cache_ttl
1069
+ if max_age is not None and self._cache_ttl is not None:
1070
+ effective_ttl = min(max_age, self._cache_ttl)
1071
+ elif max_age is not None:
1072
+ effective_ttl = max_age
1073
+
1074
+ self._jwks_cache.set(cache_key, jwks_data, ttl_seconds=effective_ttl)
1075
+ return jwks_data
789
1076
 
790
1077
  def _validate_claims_presence(
791
1078
  self,
@@ -0,0 +1,168 @@
1
+ import time
2
+ from abc import ABC, abstractmethod
3
+ from typing import Any, Optional
4
+
5
+
6
+ class CacheAdapter(ABC):
7
+ """
8
+ Abstract base class for cache implementations.
9
+
10
+ Allows custom cache backends (Redis, Memcached, etc.) to be plugged into
11
+ the ApiClient for caching OIDC discovery metadata and JWKS.
12
+
13
+ Example:
14
+ class RedisCache(CacheAdapter):
15
+ def __init__(self, redis_client):
16
+ self.redis = redis_client
17
+
18
+ def get(self, key: str) -> Optional[Any]:
19
+ value = self.redis.get(key)
20
+ return json.loads(value) if value else None
21
+
22
+ def set(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
23
+ self.redis.set(key, json.dumps(value), ex=ttl_seconds)
24
+
25
+ def delete(self, key: str) -> None:
26
+ self.redis.delete(key)
27
+
28
+ def clear(self) -> None:
29
+ self.redis.flushdb()
30
+ """
31
+
32
+ @abstractmethod
33
+ def get(self, key: str) -> Optional[Any]:
34
+ """
35
+ Get value from cache by key.
36
+
37
+ Args:
38
+ key: Cache key to retrieve
39
+
40
+ Returns:
41
+ Cached value if found and not expired, None otherwise
42
+ """
43
+ pass
44
+
45
+ @abstractmethod
46
+ def set(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
47
+ """
48
+ Set value in cache with optional TTL.
49
+
50
+ Args:
51
+ key: Cache key to store
52
+ value: Value to cache
53
+ ttl_seconds: Time-to-live in seconds. None means no expiration.
54
+ """
55
+ pass
56
+
57
+ @abstractmethod
58
+ def delete(self, key: str) -> None:
59
+ """
60
+ Delete value from cache.
61
+
62
+ Args:
63
+ key: Cache key to delete
64
+ """
65
+ pass
66
+
67
+ @abstractmethod
68
+ def clear(self) -> None:
69
+ """Clear all cache entries."""
70
+ pass
71
+
72
+
73
+ class InMemoryCache(CacheAdapter):
74
+ """
75
+ Default in-memory cache implementation with LRU eviction.
76
+
77
+ Designed for asyncio (single-threaded).
78
+ For multi-threaded environments, implement a custom CacheAdapter
79
+ with appropriate locking.
80
+
81
+ Features:
82
+ - TTL (time-to-live) support per entry using monotonic clock
83
+ - LRU (Least Recently Used) eviction when max_entries reached
84
+ - No external dependencies
85
+
86
+ Args:
87
+ max_entries: Maximum number of entries to cache. When exceeded,
88
+ least recently used entry is evicted. Default: 100.
89
+
90
+ Example:
91
+ cache = InMemoryCache(max_entries=50)
92
+ cache.set("key1", {"data": "value"}, ttl_seconds=600)
93
+ value = cache.get("key1") # Returns {"data": "value"}
94
+ """
95
+
96
+ def __init__(self, max_entries: int = 100):
97
+ """
98
+ Initialize in-memory cache.
99
+
100
+ Args:
101
+ max_entries: Maximum number of cache entries (default: 100)
102
+ """
103
+ self._cache: dict[str, tuple[Any, Optional[float]]] = {}
104
+ self._max_entries = max_entries
105
+
106
+ def get(self, key: str) -> Optional[Any]:
107
+ """
108
+ Get value from cache by key.
109
+
110
+ Updates access order for LRU tracking.
111
+
112
+ Args:
113
+ key: Cache key to retrieve
114
+
115
+ Returns:
116
+ Cached value if found and not expired, None otherwise
117
+ """
118
+ if key not in self._cache:
119
+ return None
120
+
121
+ value, expiry = self._cache[key]
122
+
123
+ if expiry is not None and time.monotonic() > expiry:
124
+ del self._cache[key]
125
+ return None
126
+
127
+ del self._cache[key]
128
+ self._cache[key] = (value, expiry)
129
+
130
+ return value
131
+
132
+ def set(self, key: str, value: Any, ttl_seconds: Optional[int] = None) -> None:
133
+ """
134
+ Set value in cache with optional TTL.
135
+
136
+ If cache is at max capacity, evicts least recently used entry.
137
+
138
+ Args:
139
+ key: Cache key to store
140
+ value: Value to cache
141
+ ttl_seconds: Time-to-live in seconds. None means no expiration.
142
+ """
143
+ # If key exists, remove first so reinsert goes to end
144
+ if key in self._cache:
145
+ del self._cache[key]
146
+ elif len(self._cache) >= self._max_entries:
147
+ # Evict LRU: first key in dict is oldest
148
+ oldest_key = next(iter(self._cache))
149
+ del self._cache[oldest_key]
150
+
151
+ expiry = None
152
+ if ttl_seconds is not None:
153
+ expiry = time.monotonic() + ttl_seconds
154
+
155
+ self._cache[key] = (value, expiry)
156
+
157
+ def delete(self, key: str) -> None:
158
+ """
159
+ Delete value from cache.
160
+
161
+ Args:
162
+ key: Cache key to delete
163
+ """
164
+ self._cache.pop(key, None)
165
+
166
+ def clear(self) -> None:
167
+ """Clear all cache entries."""
168
+ self._cache.clear()
@@ -2,7 +2,10 @@
2
2
  Configuration classes and utilities for auth0-api-python.
3
3
  """
4
4
 
5
- from typing import Callable, Optional
5
+ from typing import TYPE_CHECKING, Callable, Optional, Union
6
+
7
+ if TYPE_CHECKING:
8
+ from .cache import CacheAdapter
6
9
 
7
10
 
8
11
  class ApiClientOptions:
@@ -10,9 +13,16 @@ class ApiClientOptions:
10
13
  Configuration for the ApiClient.
11
14
 
12
15
  Args:
13
- domain: The Auth0 domain, e.g., "my-tenant.us.auth0.com".
16
+ domain: The Auth0 domain for single-domain mode and client flows,
17
+ e.g., "my-tenant.us.auth0.com". Optional if domains is provided.
18
+ domains: List of allowed domains or a resolver function for multi-domain mode.
19
+ Can be a static list of domain strings or a callable that returns
20
+ allowed domains dynamically. Optional if domain is provided.
14
21
  audience: The expected 'aud' claim in the token.
15
22
  custom_fetch: Optional callable that can replace the default HTTP fetch logic.
23
+ cache_adapter: Custom cache implementation. If not provided, uses default InMemoryCache.
24
+ cache_ttl_seconds: Time-to-live for cache entries in seconds (default: 600 = 10 minutes).
25
+ cache_max_entries: Maximum number of cache entries before LRU eviction (default: 100).
16
26
  dpop_enabled: Whether DPoP is enabled (default: True for backward compatibility).
17
27
  dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP).
18
28
  dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30).
@@ -23,9 +33,13 @@ class ApiClientOptions:
23
33
  """
24
34
  def __init__(
25
35
  self,
26
- domain: str,
27
- audience: str,
36
+ domain: Optional[str] = None,
37
+ audience: str = "",
38
+ domains: Optional[Union[list[str], Callable[[dict], list[str]]]] = None,
28
39
  custom_fetch: Optional[Callable[..., object]] = None,
40
+ cache_adapter: Optional["CacheAdapter"] = None,
41
+ cache_ttl_seconds: int = 600,
42
+ cache_max_entries: int = 100,
29
43
  dpop_enabled: bool = True,
30
44
  dpop_required: bool = False,
31
45
  dpop_iat_leeway: int = 30,
@@ -35,8 +49,12 @@ class ApiClientOptions:
35
49
  timeout: float = 10.0,
36
50
  ):
37
51
  self.domain = domain
52
+ self.domains = domains
38
53
  self.audience = audience
39
54
  self.custom_fetch = custom_fetch
55
+ self.cache_adapter = cache_adapter
56
+ self.cache_ttl_seconds = cache_ttl_seconds
57
+ self.cache_max_entries = cache_max_entries
40
58
  self.dpop_enabled = dpop_enabled
41
59
  self.dpop_required = dpop_required
42
60
  self.dpop_iat_leeway = dpop_iat_leeway
@@ -140,3 +140,23 @@ class ApiError(BaseAuthError):
140
140
 
141
141
  def get_error_code(self) -> str:
142
142
  return self.code
143
+
144
+
145
+ class ConfigurationError(BaseAuthError):
146
+ """Error raised when SDK configuration is invalid."""
147
+
148
+ def get_status_code(self) -> int:
149
+ return 500
150
+
151
+ def get_error_code(self) -> str:
152
+ return "invalid_configuration"
153
+
154
+
155
+ class DomainsResolverError(BaseAuthError):
156
+ """Error raised when domains resolver function fails."""
157
+
158
+ def get_status_code(self) -> int:
159
+ return 500
160
+
161
+ def get_error_code(self) -> str:
162
+ return "domains_resolver_error"
@@ -0,0 +1,53 @@
1
+ """
2
+ Type definitions for auth0-api-python SDK
3
+ """
4
+
5
+ from collections.abc import Awaitable, Callable
6
+ from typing import Optional, TypedDict, Union
7
+
8
+
9
+ class DomainsResolverContext(TypedDict, total=False):
10
+ """
11
+ Context passed to domains resolver functions.
12
+
13
+ Attributes:
14
+ request_url: The URL the API request was made to (optional)
15
+ request_headers: Request headers dict (e.g., Host, X-Forwarded-Host) (optional)
16
+ unverified_iss: The issuer claim from the unverified token
17
+ """
18
+ request_url: Optional[str]
19
+ request_headers: Optional[dict]
20
+ unverified_iss: str
21
+
22
+ DomainsResolver = Callable[
23
+ [DomainsResolverContext], Union[list[str], Awaitable[list[str]]]
24
+ ]
25
+ """
26
+ Type alias for domains resolver function.
27
+
28
+ A DomainsResolver is a sync or async function that receives a DomainsResolverContext
29
+ and returns a list of allowed domain strings.
30
+
31
+ Args:
32
+ context (DomainsResolverContext): Dictionary containing:
33
+ - 'request_url' (str | None): The URL the API request was made to
34
+ - 'request_headers' (dict | None): Request headers (e.g., Host, X-Forwarded-Host)
35
+ - 'unverified_iss' (str): The issuer claim from the unverified token
36
+
37
+ Returns:
38
+ list[str]: List of allowed domain strings (e.g., ['tenant.auth0.com'])
39
+
40
+ Example (sync):
41
+ from auth0_api_python import DomainsResolverContext
42
+
43
+ def my_resolver(context: DomainsResolverContext) -> list[str]:
44
+ host = (context.get('request_headers') or {}).get('host')
45
+ if host == 'api.brand.com':
46
+ return ['brand.custom-domain.com']
47
+ return ['tenant.auth0.com']
48
+
49
+ Example (async):
50
+ async def my_async_resolver(context: DomainsResolverContext) -> list[str]:
51
+ domains = await db.lookup_domains(context['unverified_iss'])
52
+ return domains
53
+ """
auth0_api_python/utils.py CHANGED
@@ -7,57 +7,147 @@ import base64
7
7
  import hashlib
8
8
  import json
9
9
  import re
10
+ from collections.abc import Mapping
10
11
  from typing import Any, Callable, Optional, Union
11
12
 
12
13
  import httpx
13
14
  from ada_url import URL
14
15
 
15
16
 
17
+ def parse_cache_control_max_age(headers: Mapping[str, str]) -> Optional[int]:
18
+ """
19
+ Parse the max-age directive from a Cache-Control HTTP header.
20
+
21
+ Args:
22
+ headers: HTTP response headers (dict-like, supports case-insensitive
23
+ access for httpx Headers objects)
24
+
25
+ Returns:
26
+ max-age value in seconds, or None if not present or unparseable
27
+ """
28
+ cache_control = headers.get("cache-control") or headers.get("Cache-Control")
29
+ if not cache_control:
30
+ return None
31
+
32
+ for directive in cache_control.split(","):
33
+ directive = directive.strip().lower()
34
+ if directive.startswith("max-age="):
35
+ try:
36
+ value = int(directive[8:].strip())
37
+ return value if value >= 0 else None
38
+ except ValueError:
39
+ return None
40
+
41
+ return None
42
+
43
+
44
+ def normalize_domain(domain: str) -> str:
45
+ """
46
+ Normalize a domain string to a standard issuer URL format.
47
+
48
+ Args:
49
+ domain: Domain string in any format (e.g., "tenant.auth0.com",
50
+ "https://tenant.auth0.com/", "TENANT.AUTH0.COM")
51
+
52
+ Returns:
53
+ Normalized issuer URL (e.g., "https://tenant.auth0.com/")
54
+
55
+ """
56
+ if not isinstance(domain, str) or not domain.strip():
57
+ raise ValueError("domain must be a non-empty string")
58
+
59
+ domain = domain.strip().lower()
60
+
61
+ # Reject http:// explicitly
62
+ if domain.startswith('http://'):
63
+ raise ValueError("invalid domain URL (https required)")
64
+
65
+ # Strip https:// prefix
66
+ domain = domain.replace('https://', '')
67
+
68
+ # Split host from any path/query/fragment
69
+ host = domain.split('/')[0].split('?')[0].split('#')[0]
70
+
71
+ # Reject credentials
72
+ if '@' in host:
73
+ raise ValueError("invalid domain URL (credentials are not allowed)")
74
+
75
+ # Check for path segments, query, or fragment
76
+ bare = domain.rstrip('/')
77
+ if bare != host:
78
+ raise ValueError(
79
+ "invalid domain URL (path/query/fragment are not allowed)"
80
+ )
81
+
82
+ return f"https://{host}/"
83
+
84
+
16
85
  async def fetch_oidc_metadata(
17
86
  domain: str,
18
87
  custom_fetch: Optional[Callable[..., Any]] = None
19
- ) -> dict[str, Any]:
88
+ ) -> tuple[dict[str, Any], Optional[int]]:
20
89
  """
21
90
  Asynchronously fetch the OIDC config from https://{domain}/.well-known/openid-configuration.
22
- Returns a dict with keys like issuer, jwks_uri, authorization_endpoint, etc.
23
- If custom_fetch is provided, we call it instead of httpx.
91
+
92
+ Returns:
93
+ Tuple of (metadata_dict, max_age_or_none). max_age is parsed from
94
+ the Cache-Control response header if present.
24
95
  """
25
96
  url = f"https://{domain}/.well-known/openid-configuration"
26
97
  if custom_fetch:
27
98
  response = await custom_fetch(url)
28
- return response.json() if hasattr(response, "json") else response
99
+ if hasattr(response, "json"):
100
+ data = response.json()
101
+ max_age = parse_cache_control_max_age(response.headers) if hasattr(response, "headers") else None
102
+ return data, max_age
103
+ return response, None
29
104
  else:
30
105
  async with httpx.AsyncClient() as client:
31
106
  resp = await client.get(url)
32
107
  resp.raise_for_status()
33
- return resp.json()
108
+ max_age = parse_cache_control_max_age(resp.headers)
109
+ return resp.json(), max_age
34
110
 
35
111
 
36
112
  async def fetch_jwks(
37
113
  jwks_uri: str,
38
114
  custom_fetch: Optional[Callable[..., Any]] = None
39
- ) -> dict[str, Any]:
115
+ ) -> tuple[dict[str, Any], Optional[int]]:
40
116
  """
41
117
  Asynchronously fetch the JSON Web Key Set from jwks_uri.
42
- Returns the raw JWKS JSON, e.g. {'keys': [...]}
43
118
 
44
- If custom_fetch is provided, it must be an async callable
45
- that fetches data from the jwks_uri.
119
+ Returns:
120
+ Tuple of (jwks_dict, max_age_or_none). max_age is parsed from
121
+ the Cache-Control response header if present.
46
122
  """
47
123
  if custom_fetch:
48
124
  response = await custom_fetch(jwks_uri)
49
- return response.json() if hasattr(response, "json") else response
125
+ if hasattr(response, "json"):
126
+ data = response.json()
127
+ max_age = parse_cache_control_max_age(response.headers) if hasattr(response, "headers") else None
128
+ return data, max_age
129
+ return response, None
50
130
  else:
51
131
  async with httpx.AsyncClient() as client:
52
132
  resp = await client.get(jwks_uri)
53
133
  resp.raise_for_status()
54
- return resp.json()
134
+ max_age = parse_cache_control_max_age(resp.headers)
135
+ return resp.json(), max_age
55
136
 
56
137
 
57
- def get_unverified_header(token: Union[str, bytes]) -> dict:
138
+ def _decode_jwt_segment(token: Union[str, bytes], segment_index: int) -> dict:
58
139
  """
59
- Parse the first segment (header) of a JWT without verifying signature.
60
- Ensures correct Base64 padding before decode to avoid garbage bytes.
140
+ Decode a specific segment from a JWT without verifying signature.
141
+
142
+ Args:
143
+ token: The JWT token (string or bytes)
144
+ segment_index: 0 for header, 1 for payload
145
+
146
+ Returns:
147
+ Decoded segment as dictionary
148
+
149
+ Raises:
150
+ ValueError: If token format is invalid
61
151
  """
62
152
  if isinstance(token, bytes):
63
153
  token = token.decode("utf-8")
@@ -66,12 +156,38 @@ def get_unverified_header(token: Union[str, bytes]) -> dict:
66
156
  if len(parts) != 3:
67
157
  raise ValueError(f"Invalid token format: expected 3 segments, got {len(parts)}")
68
158
 
69
- header_b64 = parts[0]
70
- header_b64 = remove_bytes_prefix(header_b64)
71
- header_b64 = fix_base64_padding(header_b64)
159
+ segment_b64 = parts[segment_index]
160
+ segment_b64 = remove_bytes_prefix(segment_b64)
161
+ segment_b64 = fix_base64_padding(segment_b64)
162
+
163
+ segment_data = base64.urlsafe_b64decode(segment_b64)
164
+ return json.loads(segment_data)
72
165
 
73
- header_data = base64.urlsafe_b64decode(header_b64)
74
- return json.loads(header_data)
166
+
167
+ def get_unverified_header(token: Union[str, bytes]) -> dict:
168
+ """
169
+ Parse the JWT header without verifying signature.
170
+
171
+ Args:
172
+ token: The JWT token
173
+
174
+ Returns:
175
+ Decoded header as dictionary
176
+ """
177
+ return _decode_jwt_segment(token, 0)
178
+
179
+
180
+ def get_unverified_payload(token: Union[str, bytes]) -> dict:
181
+ """
182
+ Parse the JWT payload without verifying signature.
183
+
184
+ Args:
185
+ token: The JWT token
186
+
187
+ Returns:
188
+ Decoded payload (claims) as dictionary
189
+ """
190
+ return _decode_jwt_segment(token, 1)
75
191
 
76
192
 
77
193
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth0-api-python
3
- Version: 1.0.0b6
3
+ Version: 1.0.0b8
4
4
  Summary: SDK for verifying access tokens and securing APIs with Auth0, using Authlib.
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -15,7 +15,8 @@ Classifier: Programming Language :: Python :: 3.11
15
15
  Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Programming Language :: Python :: 3.14
18
- Requires-Dist: ada-url (>=1.27.0,<2.0.0)
18
+ Requires-Dist: ada-url (>=1.27.0,<2.0.0) ; python_version == "3.9"
19
+ Requires-Dist: ada-url (>=1.30.0,<2.0.0) ; python_version >= "3.10"
19
20
  Requires-Dist: authlib (>=1.0,<2.0)
20
21
  Requires-Dist: httpx (>=0.28.1,<0.29.0)
21
22
  Requires-Dist: requests (>=2.31.0,<3.0.0)
@@ -40,7 +41,8 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce
40
41
 
41
42
  ### **Core Features**
42
43
  - **Unified Entry Point**: `verify_request()` - automatically detects and validates Bearer or DPoP schemes
43
- - **OIDC Discovery** - Automatic fetching of Auth0 metadata and JWKS
44
+ - **Multi-Custom Domain (MCD)** - Accept tokens from multiple Auth0 domains with static lists or dynamic resolvers
45
+ - **OIDC Discovery** - Automatic fetching of Auth0 metadata and JWKS with per-issuer caching
44
46
  - **JWT Validation** - Complete RS256 signature verification with claim validation
45
47
  - **DPoP Proof Verification** - Full RFC 9449 compliance with ES256 signature validation
46
48
  - **Flexible Configuration** - Support for both "Allowed" and "Required" DPoP modes
@@ -249,9 +251,6 @@ If the token lacks `my_custom_claim` or fails any standard check (issuer mismatc
249
251
 
250
252
  ### 6. DPoP Authentication
251
253
 
252
- > [!NOTE]
253
- > This feature is currently available in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant.
254
-
255
254
  This library supports **DPoP (Demonstrating Proof-of-Possession)** for enhanced security, allowing clients to prove possession of private keys bound to access tokens.
256
255
 
257
256
  #### Allowed Mode (Default)
@@ -302,6 +301,50 @@ api_client = ApiClient(ApiClientOptions(
302
301
  ))
303
302
  ```
304
303
 
304
+ ### 7. Multi-Custom Domain (MCD) Support
305
+
306
+ If your Auth0 tenant has multiple custom domains, or you're migrating between domains, the SDK can accept tokens from any of them:
307
+
308
+ #### Static Domain List
309
+
310
+ ```python
311
+ from auth0_api_python import ApiClient, ApiClientOptions
312
+
313
+ api_client = ApiClient(ApiClientOptions(
314
+ domains=[
315
+ "tenant.auth0.com",
316
+ "auth.example.com",
317
+ "auth.acme.org"
318
+ ],
319
+ audience="https://api.example.com"
320
+ ))
321
+
322
+ # Tokens from any of the three domains are accepted
323
+ claims = await api_client.verify_access_token(access_token)
324
+ ```
325
+
326
+ #### Dynamic Resolver
327
+
328
+ For runtime domain resolution based on request context:
329
+
330
+ ```python
331
+ from auth0_api_python import ApiClient, ApiClientOptions, DomainsResolverContext
332
+
333
+ def resolve_domains(context: DomainsResolverContext) -> list[str]:
334
+ # Determine allowed domains based on the request
335
+ return ["tenant.auth0.com", "auth.example.com"]
336
+
337
+ api_client = ApiClient(ApiClientOptions(
338
+ domains=resolve_domains,
339
+ audience="https://api.example.com"
340
+ ))
341
+ ```
342
+
343
+ For hybrid mode (migration scenarios), resolver patterns, error handling, and caching configuration, see the full guides:
344
+
345
+ - **[Multi-Custom Domain Guide](docs/MultipleCustomDomain.md)** - Configuration modes, resolver patterns, migration, error handling
346
+ - **[Caching Guide](docs/Caching.md)** - Cache tuning, custom adapters (Redis, Memcached)
347
+
305
348
  ## Feedback
306
349
 
307
350
  ### Contributing
@@ -0,0 +1,12 @@
1
+ auth0_api_python/__init__.py,sha256=z8npiH8GBhUexAn_ejh4pnq865dXy7xSaNkQDYJwgGM,725
2
+ auth0_api_python/api_client.py,sha256=pfnyTauurTXQ8_txqE8ZH20UZqXFv9I_EGbn6-9HXgU,48864
3
+ auth0_api_python/cache.py,sha256=MYLssrVx9dYYDNi5JHWHQZgvNrohPtHRKu9n3A76G-I,4689
4
+ auth0_api_python/config.py,sha256=3S0VIO12FCxPPUz15MSMJC5whao3J0u0aSnEivznbZs,3020
5
+ auth0_api_python/errors.py,sha256=5yAwWy7Y9eMtWO-dYRnTfj66ZXCGSzfSqvF3lcPSixI,4513
6
+ auth0_api_python/token_utils.py,sha256=iWCCy15TSjSS6b-2hWpeFY8bDXComkOFI9cINgoGIWs,8026
7
+ auth0_api_python/types.py,sha256=VmOxBmNThn8Q7JVOv6Nloqu6RZ4v8jp5PxJWJFaxtD4,1789
8
+ auth0_api_python/utils.py,sha256=vsdLCzy367xOKlxWE2G8Mu62CZ9wp6fLXl9NSnHPIf8,8285
9
+ auth0_api_python-1.0.0b8.dist-info/METADATA,sha256=oLDtdMsHG6wAOxABXG64ph7D-c_-gc8q_oQxslsgW1o,15104
10
+ auth0_api_python-1.0.0b8.dist-info/WHEEL,sha256=Vz2fHgx6HFtSwhs8KvkHLqH5Ea4w1_rner5uNVGCeIE,88
11
+ auth0_api_python-1.0.0b8.dist-info/licenses/LICENSE,sha256=EEi9tibVAgKdGU1DmXmHjZFOwMfYXDV45mabfEmULkQ,1116
12
+ auth0_api_python-1.0.0b8.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: poetry-core 2.2.1
2
+ Generator: poetry-core 2.3.2
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,10 +0,0 @@
1
- auth0_api_python/__init__.py,sha256=mFAanTbu-DXqxV-VHA7awj76CjQ6u4LrKD_b6gBFtcE,407
2
- auth0_api_python/api_client.py,sha256=Kv6Mm-ZcMY5LTXYI0SPjA9WvAG0LwzcOIbFTnRY48bM,37725
3
- auth0_api_python/config.py,sha256=VL2VpFz7GFu9Pf31t0Bo7dJ6lpYJEwwlwm-BZ6T2Ygg,1889
4
- auth0_api_python/errors.py,sha256=eSwwl1Fb0E5C78oFSFj70cYep-wGe6ddKVPJZDNn1gQ,4035
5
- auth0_api_python/token_utils.py,sha256=iWCCy15TSjSS6b-2hWpeFY8bDXComkOFI9cINgoGIWs,8026
6
- auth0_api_python/utils.py,sha256=bIMtKe3WOtut0YpaFvPwMs_OO0Q9DWeDVoExKmg-txg,4976
7
- auth0_api_python-1.0.0b6.dist-info/METADATA,sha256=mO9dNrpNiiT3salWMU5NzwZ7ifIZcFL9U78QtHMSges,13724
8
- auth0_api_python-1.0.0b6.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
9
- auth0_api_python-1.0.0b6.dist-info/licenses/LICENSE,sha256=EEi9tibVAgKdGU1DmXmHjZFOwMfYXDV45mabfEmULkQ,1116
10
- auth0_api_python-1.0.0b6.dist-info/RECORD,,