auth0-api-python 1.0.0b7__tar.gz → 1.0.0b8__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: auth0-api-python
3
- Version: 1.0.0b7
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
@@ -41,7 +41,8 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce
41
41
 
42
42
  ### **Core Features**
43
43
  - **Unified Entry Point**: `verify_request()` - automatically detects and validates Bearer or DPoP schemes
44
- - **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
45
46
  - **JWT Validation** - Complete RS256 signature verification with claim validation
46
47
  - **DPoP Proof Verification** - Full RFC 9449 compliance with ES256 signature validation
47
48
  - **Flexible Configuration** - Support for both "Allowed" and "Required" DPoP modes
@@ -250,9 +251,6 @@ If the token lacks `my_custom_claim` or fails any standard check (issuer mismatc
250
251
 
251
252
  ### 6. DPoP Authentication
252
253
 
253
- > [!NOTE]
254
- > 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.
255
-
256
254
  This library supports **DPoP (Demonstrating Proof-of-Possession)** for enhanced security, allowing clients to prove possession of private keys bound to access tokens.
257
255
 
258
256
  #### Allowed Mode (Default)
@@ -303,6 +301,50 @@ api_client = ApiClient(ApiClientOptions(
303
301
  ))
304
302
  ```
305
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
+
306
348
  ## Feedback
307
349
 
308
350
  ### Contributing
@@ -17,7 +17,8 @@ This SDK provides comprehensive support for securing APIs with Auth0-issued acce
17
17
 
18
18
  ### **Core Features**
19
19
  - **Unified Entry Point**: `verify_request()` - automatically detects and validates Bearer or DPoP schemes
20
- - **OIDC Discovery** - Automatic fetching of Auth0 metadata and JWKS
20
+ - **Multi-Custom Domain (MCD)** - Accept tokens from multiple Auth0 domains with static lists or dynamic resolvers
21
+ - **OIDC Discovery** - Automatic fetching of Auth0 metadata and JWKS with per-issuer caching
21
22
  - **JWT Validation** - Complete RS256 signature verification with claim validation
22
23
  - **DPoP Proof Verification** - Full RFC 9449 compliance with ES256 signature validation
23
24
  - **Flexible Configuration** - Support for both "Allowed" and "Required" DPoP modes
@@ -226,9 +227,6 @@ If the token lacks `my_custom_claim` or fails any standard check (issuer mismatc
226
227
 
227
228
  ### 6. DPoP Authentication
228
229
 
229
- > [!NOTE]
230
- > 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.
231
-
232
230
  This library supports **DPoP (Demonstrating Proof-of-Possession)** for enhanced security, allowing clients to prove possession of private keys bound to access tokens.
233
231
 
234
232
  #### Allowed Mode (Default)
@@ -279,6 +277,50 @@ api_client = ApiClient(ApiClientOptions(
279
277
  ))
280
278
  ```
281
279
 
280
+ ### 7. Multi-Custom Domain (MCD) Support
281
+
282
+ If your Auth0 tenant has multiple custom domains, or you're migrating between domains, the SDK can accept tokens from any of them:
283
+
284
+ #### Static Domain List
285
+
286
+ ```python
287
+ from auth0_api_python import ApiClient, ApiClientOptions
288
+
289
+ api_client = ApiClient(ApiClientOptions(
290
+ domains=[
291
+ "tenant.auth0.com",
292
+ "auth.example.com",
293
+ "auth.acme.org"
294
+ ],
295
+ audience="https://api.example.com"
296
+ ))
297
+
298
+ # Tokens from any of the three domains are accepted
299
+ claims = await api_client.verify_access_token(access_token)
300
+ ```
301
+
302
+ #### Dynamic Resolver
303
+
304
+ For runtime domain resolution based on request context:
305
+
306
+ ```python
307
+ from auth0_api_python import ApiClient, ApiClientOptions, DomainsResolverContext
308
+
309
+ def resolve_domains(context: DomainsResolverContext) -> list[str]:
310
+ # Determine allowed domains based on the request
311
+ return ["tenant.auth0.com", "auth.example.com"]
312
+
313
+ api_client = ApiClient(ApiClientOptions(
314
+ domains=resolve_domains,
315
+ audience="https://api.example.com"
316
+ ))
317
+ ```
318
+
319
+ For hybrid mode (migration scenarios), resolver patterns, error handling, and caching configuration, see the full guides:
320
+
321
+ - **[Multi-Custom Domain Guide](docs/MultipleCustomDomain.md)** - Configuration modes, resolver patterns, migration, error handling
322
+ - **[Caching Guide](docs/Caching.md)** - Cache tuning, custom adapters (Redis, Memcached)
323
+
282
324
  ## Feedback
283
325
 
284
326
  ### Contributing
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "auth0-api-python"
3
- version = "1.0.0.b7"
3
+ version = "1.0.0b8"
4
4
  description = "SDK for verifying access tokens and securing APIs with Auth0, using Authlib."
5
5
  authors = ["Auth0 <support@auth0.com>"]
6
6
  license = "MIT"
@@ -0,0 +1,30 @@
1
+ """
2
+ auth0-api-python
3
+
4
+ A lightweight Python SDK for verifying Auth0-issued access tokens
5
+ in server-side APIs, using Authlib for OIDC discovery and JWKS fetching.
6
+ """
7
+
8
+ from .api_client import ApiClient
9
+ from .cache import CacheAdapter, InMemoryCache
10
+ from .config import ApiClientOptions
11
+ from .errors import (
12
+ ApiError,
13
+ ConfigurationError,
14
+ DomainsResolverError,
15
+ GetTokenByExchangeProfileError,
16
+ )
17
+ from .types import DomainsResolver, DomainsResolverContext
18
+
19
+ __all__ = [
20
+ "ApiClient",
21
+ "ApiClientOptions",
22
+ "ApiError",
23
+ "CacheAdapter",
24
+ "ConfigurationError",
25
+ "DomainsResolver",
26
+ "DomainsResolverContext",
27
+ "DomainsResolverError",
28
+ "GetTokenByExchangeProfileError",
29
+ "InMemoryCache",
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,