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.
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/PKG-INFO +47 -5
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/README.md +46 -4
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/pyproject.toml +1 -1
- auth0_api_python-1.0.0b8/src/auth0_api_python/__init__.py +30 -0
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/src/auth0_api_python/api_client.py +321 -34
- auth0_api_python-1.0.0b8/src/auth0_api_python/cache.py +168 -0
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/src/auth0_api_python/config.py +22 -4
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/src/auth0_api_python/errors.py +20 -0
- auth0_api_python-1.0.0b8/src/auth0_api_python/types.py +53 -0
- auth0_api_python-1.0.0b8/src/auth0_api_python/utils.py +273 -0
- auth0_api_python-1.0.0b7/src/auth0_api_python/__init__.py +0 -17
- auth0_api_python-1.0.0b7/src/auth0_api_python/utils.py +0 -157
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/LICENSE +0 -0
- {auth0_api_python-1.0.0b7 → auth0_api_python-1.0.0b8}/src/auth0_api_python/token_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: auth0-api-python
|
|
3
|
-
Version: 1.0.
|
|
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
|
-
- **
|
|
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
|
-
- **
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
"""
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
)
|
|
788
|
-
|
|
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,
|