auth0-api-python 1.0.0b2__py3-none-any.whl → 1.0.0b4__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.
- {src → auth0_api_python}/__init__.py +1 -1
- auth0_api_python/api_client.py +552 -0
- auth0_api_python/config.py +37 -0
- auth0_api_python/errors.py +96 -0
- auth0_api_python/token_utils.py +221 -0
- auth0_api_python/utils.py +157 -0
- {auth0_api_python-1.0.0b2.dist-info → auth0_api_python-1.0.0b4.dist-info}/METADATA +77 -3
- auth0_api_python-1.0.0b4.dist-info/RECORD +10 -0
- {auth0_api_python-1.0.0b2.dist-info → auth0_api_python-1.0.0b4.dist-info}/WHEEL +1 -1
- auth0_api_python-1.0.0b2.dist-info/RECORD +0 -10
- src/api_client.py +0 -128
- src/config.py +0 -24
- src/errors.py +0 -21
- src/token_utils.py +0 -84
- src/utils.py +0 -88
- {auth0_api_python-1.0.0b2.dist-info → auth0_api_python-1.0.0b4.dist-info}/LICENSE +0 -0
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
|
|
4
|
+
from authlib.jose import JsonWebKey, JsonWebToken
|
|
5
|
+
|
|
6
|
+
from .config import ApiClientOptions
|
|
7
|
+
from .errors import (
|
|
8
|
+
BaseAuthError,
|
|
9
|
+
InvalidAuthSchemeError,
|
|
10
|
+
InvalidDpopProofError,
|
|
11
|
+
MissingAuthorizationError,
|
|
12
|
+
MissingRequiredArgumentError,
|
|
13
|
+
VerifyAccessTokenError,
|
|
14
|
+
)
|
|
15
|
+
from .utils import (
|
|
16
|
+
calculate_jwk_thumbprint,
|
|
17
|
+
fetch_jwks,
|
|
18
|
+
fetch_oidc_metadata,
|
|
19
|
+
get_unverified_header,
|
|
20
|
+
normalize_url_for_htu,
|
|
21
|
+
sha256_base64url,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ApiClient:
|
|
26
|
+
"""
|
|
27
|
+
The main class for discovering OIDC metadata (issuer, jwks_uri) and verifying
|
|
28
|
+
Auth0-issued JWT access tokens in an async environment.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, options: ApiClientOptions):
|
|
32
|
+
if not options.domain:
|
|
33
|
+
raise MissingRequiredArgumentError("domain")
|
|
34
|
+
if not options.audience:
|
|
35
|
+
raise MissingRequiredArgumentError("audience")
|
|
36
|
+
|
|
37
|
+
self.options = options
|
|
38
|
+
self._metadata: Optional[dict[str, Any]] = None
|
|
39
|
+
self._jwks_data: Optional[dict[str, Any]] = None
|
|
40
|
+
|
|
41
|
+
self._jwt = JsonWebToken(["RS256"])
|
|
42
|
+
|
|
43
|
+
self._dpop_algorithms = ["ES256"]
|
|
44
|
+
self._dpop_jwt = JsonWebToken(self._dpop_algorithms)
|
|
45
|
+
|
|
46
|
+
def is_dpop_required(self) -> bool:
|
|
47
|
+
"""Check if DPoP authentication is required."""
|
|
48
|
+
return getattr(self.options, "dpop_required", False)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def verify_request(
|
|
52
|
+
self,
|
|
53
|
+
headers: dict[str, str],
|
|
54
|
+
http_method: Optional[str] = None,
|
|
55
|
+
http_url: Optional[str] = None
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""
|
|
58
|
+
Dispatch based on Authorization scheme:
|
|
59
|
+
• If scheme is 'DPoP', verifies both access token and DPoP proof
|
|
60
|
+
• If scheme is 'Bearer', verifies only the access token
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
headers: HTTP headers dict containing (header keys should be lowercase):
|
|
64
|
+
- "authorization": The Authorization header value (required)
|
|
65
|
+
- "dpop": The DPoP proof header value (required for DPoP)
|
|
66
|
+
http_method: The HTTP method (required for DPoP)
|
|
67
|
+
http_url: The HTTP URL (required for DPoP)
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The decoded access token claims
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
MissingRequiredArgumentError: If required args are missing
|
|
74
|
+
InvalidAuthSchemeError: If an unsupported scheme is provided
|
|
75
|
+
InvalidDpopProofError: If DPoP verification fails
|
|
76
|
+
VerifyAccessTokenError: If access token verification fails
|
|
77
|
+
"""
|
|
78
|
+
authorization_header = headers.get("authorization", "")
|
|
79
|
+
dpop_proof = headers.get("dpop")
|
|
80
|
+
|
|
81
|
+
if not authorization_header:
|
|
82
|
+
if self.is_dpop_required():
|
|
83
|
+
raise self._prepare_error(
|
|
84
|
+
InvalidAuthSchemeError("")
|
|
85
|
+
)
|
|
86
|
+
else :
|
|
87
|
+
raise self._prepare_error(MissingAuthorizationError())
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
parts = authorization_header.split(" ")
|
|
91
|
+
if len(parts) != 2:
|
|
92
|
+
if len(parts) < 2:
|
|
93
|
+
raise self._prepare_error(MissingAuthorizationError())
|
|
94
|
+
elif len(parts) > 2:
|
|
95
|
+
raise self._prepare_error(
|
|
96
|
+
InvalidAuthSchemeError("")
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
scheme, token = parts
|
|
100
|
+
|
|
101
|
+
scheme = scheme.strip().lower()
|
|
102
|
+
|
|
103
|
+
if self.is_dpop_required() and scheme != "dpop":
|
|
104
|
+
raise self._prepare_error(
|
|
105
|
+
InvalidAuthSchemeError(""),
|
|
106
|
+
auth_scheme=scheme
|
|
107
|
+
)
|
|
108
|
+
if not token.strip():
|
|
109
|
+
raise self._prepare_error(MissingAuthorizationError())
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if scheme == "dpop":
|
|
113
|
+
if not self.options.dpop_enabled:
|
|
114
|
+
raise self._prepare_error(MissingAuthorizationError())
|
|
115
|
+
|
|
116
|
+
if not dpop_proof:
|
|
117
|
+
if self.is_dpop_required():
|
|
118
|
+
raise self._prepare_error(
|
|
119
|
+
InvalidAuthSchemeError(""),
|
|
120
|
+
auth_scheme=scheme
|
|
121
|
+
)
|
|
122
|
+
else:
|
|
123
|
+
raise self._prepare_error(
|
|
124
|
+
InvalidAuthSchemeError(""),
|
|
125
|
+
auth_scheme=scheme
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
if "," in dpop_proof:
|
|
129
|
+
raise self._prepare_error(
|
|
130
|
+
InvalidDpopProofError("Multiple DPoP proofs are not allowed"),
|
|
131
|
+
auth_scheme=scheme
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
dpop_header = get_unverified_header(dpop_proof)
|
|
136
|
+
except Exception:
|
|
137
|
+
raise self._prepare_error(InvalidDpopProofError("Failed to verify DPoP proof"), auth_scheme=scheme)
|
|
138
|
+
|
|
139
|
+
if not http_method or not http_url:
|
|
140
|
+
missing_params = []
|
|
141
|
+
if not http_method:
|
|
142
|
+
missing_params.append("http_method")
|
|
143
|
+
if not http_url:
|
|
144
|
+
missing_params.append("http_url")
|
|
145
|
+
|
|
146
|
+
raise self._prepare_error(
|
|
147
|
+
MissingRequiredArgumentError(f"DPoP authentication requires {' and '.join(missing_params)}"),
|
|
148
|
+
auth_scheme=scheme
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
access_token_claims = await self.verify_access_token(token)
|
|
153
|
+
except VerifyAccessTokenError as e:
|
|
154
|
+
raise self._prepare_error(e, auth_scheme=scheme)
|
|
155
|
+
|
|
156
|
+
cnf_claim = access_token_claims.get("cnf")
|
|
157
|
+
|
|
158
|
+
if not cnf_claim:
|
|
159
|
+
raise self._prepare_error(
|
|
160
|
+
VerifyAccessTokenError("JWT Access Token has no jkt confirmation claim"),
|
|
161
|
+
auth_scheme=scheme
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if not isinstance(cnf_claim, dict):
|
|
165
|
+
raise self._prepare_error(
|
|
166
|
+
VerifyAccessTokenError("JWT Access Token has invalid confirmation claim format"),
|
|
167
|
+
auth_scheme=scheme
|
|
168
|
+
)
|
|
169
|
+
try:
|
|
170
|
+
await self.verify_dpop_proof(
|
|
171
|
+
access_token=token,
|
|
172
|
+
proof=dpop_proof,
|
|
173
|
+
http_method=http_method,
|
|
174
|
+
http_url=http_url
|
|
175
|
+
)
|
|
176
|
+
except InvalidDpopProofError as e:
|
|
177
|
+
raise self._prepare_error(e, auth_scheme=scheme)
|
|
178
|
+
|
|
179
|
+
# DPoP binding verification
|
|
180
|
+
jwk_dict = dpop_header["jwk"]
|
|
181
|
+
actual_jkt = calculate_jwk_thumbprint(jwk_dict)
|
|
182
|
+
expected_jkt = cnf_claim.get("jkt")
|
|
183
|
+
|
|
184
|
+
if not expected_jkt:
|
|
185
|
+
raise self._prepare_error(
|
|
186
|
+
VerifyAccessTokenError("Access token 'cnf' claim missing 'jkt'"),
|
|
187
|
+
auth_scheme=scheme
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
if expected_jkt != actual_jkt:
|
|
191
|
+
raise self._prepare_error(
|
|
192
|
+
VerifyAccessTokenError("JWT Access Token confirmation mismatch"),
|
|
193
|
+
auth_scheme=scheme
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return access_token_claims
|
|
197
|
+
|
|
198
|
+
if scheme == "bearer":
|
|
199
|
+
try:
|
|
200
|
+
claims = await self.verify_access_token(token)
|
|
201
|
+
if claims.get("cnf") and isinstance(claims["cnf"], dict) and claims["cnf"].get("jkt"):
|
|
202
|
+
if self.options.dpop_enabled:
|
|
203
|
+
raise self._prepare_error(
|
|
204
|
+
VerifyAccessTokenError(
|
|
205
|
+
"DPoP-bound token requires the DPoP authentication scheme, not Bearer"
|
|
206
|
+
),
|
|
207
|
+
auth_scheme=scheme
|
|
208
|
+
)
|
|
209
|
+
if dpop_proof:
|
|
210
|
+
if self.options.dpop_enabled:
|
|
211
|
+
raise self._prepare_error(
|
|
212
|
+
InvalidAuthSchemeError(
|
|
213
|
+
"DPoP proof requires DPoP authentication scheme, not Bearer"
|
|
214
|
+
),
|
|
215
|
+
auth_scheme=scheme
|
|
216
|
+
)
|
|
217
|
+
return claims
|
|
218
|
+
except VerifyAccessTokenError as e:
|
|
219
|
+
raise self._prepare_error(e, auth_scheme=scheme)
|
|
220
|
+
|
|
221
|
+
raise self._prepare_error(MissingAuthorizationError())
|
|
222
|
+
|
|
223
|
+
async def verify_access_token(
|
|
224
|
+
self,
|
|
225
|
+
access_token: str,
|
|
226
|
+
required_claims: Optional[list[str]] = None
|
|
227
|
+
) -> dict[str, Any]:
|
|
228
|
+
"""
|
|
229
|
+
Asynchronously verifies the provided JWT access token.
|
|
230
|
+
|
|
231
|
+
- Fetches OIDC metadata and JWKS if not already cached.
|
|
232
|
+
- Decodes and validates signature (RS256) with the correct key.
|
|
233
|
+
- Checks standard claims: 'iss', 'aud', 'exp', 'iat'
|
|
234
|
+
- Checks extra required claims if 'required_claims' is provided.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
The decoded token claims if valid.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
MissingRequiredArgumentError: If no token is provided.
|
|
241
|
+
VerifyAccessTokenError: If verification fails (signature, claims mismatch, etc.).
|
|
242
|
+
"""
|
|
243
|
+
if not access_token:
|
|
244
|
+
raise MissingRequiredArgumentError("access_token")
|
|
245
|
+
|
|
246
|
+
required_claims = required_claims or []
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
header = get_unverified_header(access_token)
|
|
250
|
+
kid = header["kid"]
|
|
251
|
+
except Exception as e:
|
|
252
|
+
raise VerifyAccessTokenError(f"Failed to parse token header: {str(e)}") from e
|
|
253
|
+
|
|
254
|
+
jwks_data = await self._load_jwks()
|
|
255
|
+
matching_key_dict = None
|
|
256
|
+
for key_dict in jwks_data["keys"]:
|
|
257
|
+
if key_dict.get("kid") == kid:
|
|
258
|
+
matching_key_dict = key_dict
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if not matching_key_dict:
|
|
262
|
+
raise VerifyAccessTokenError(f"No matching key found for kid: {kid}")
|
|
263
|
+
|
|
264
|
+
public_key = JsonWebKey.import_key(matching_key_dict)
|
|
265
|
+
|
|
266
|
+
if isinstance(access_token, str) and access_token.startswith("b'"):
|
|
267
|
+
access_token = access_token[2:-1]
|
|
268
|
+
try:
|
|
269
|
+
claims = self._jwt.decode(access_token, public_key)
|
|
270
|
+
except Exception as e:
|
|
271
|
+
raise VerifyAccessTokenError(f"Signature verification failed: {str(e)}") from e
|
|
272
|
+
|
|
273
|
+
metadata = await self._discover()
|
|
274
|
+
issuer = metadata["issuer"]
|
|
275
|
+
|
|
276
|
+
if claims.get("iss") != issuer:
|
|
277
|
+
raise VerifyAccessTokenError("Issuer mismatch")
|
|
278
|
+
|
|
279
|
+
expected_aud = self.options.audience
|
|
280
|
+
actual_aud = claims.get("aud")
|
|
281
|
+
|
|
282
|
+
if isinstance(actual_aud, list):
|
|
283
|
+
if expected_aud not in actual_aud:
|
|
284
|
+
raise VerifyAccessTokenError("Audience mismatch (not in token's aud array)")
|
|
285
|
+
else:
|
|
286
|
+
if actual_aud != expected_aud:
|
|
287
|
+
raise VerifyAccessTokenError("Audience mismatch (single aud)")
|
|
288
|
+
|
|
289
|
+
now = int(time.time())
|
|
290
|
+
if "exp" not in claims or now >= claims["exp"]:
|
|
291
|
+
raise VerifyAccessTokenError("Token is expired")
|
|
292
|
+
if "iat" not in claims:
|
|
293
|
+
raise VerifyAccessTokenError("Missing 'iat' claim in token")
|
|
294
|
+
|
|
295
|
+
# Additional required_claims
|
|
296
|
+
for rc in required_claims:
|
|
297
|
+
if rc not in claims:
|
|
298
|
+
raise VerifyAccessTokenError(f"Missing required claim: {rc}")
|
|
299
|
+
|
|
300
|
+
return claims
|
|
301
|
+
|
|
302
|
+
async def verify_dpop_proof(
|
|
303
|
+
self,
|
|
304
|
+
access_token: str,
|
|
305
|
+
proof: str,
|
|
306
|
+
http_method: str,
|
|
307
|
+
http_url: str
|
|
308
|
+
) -> dict[str, Any]:
|
|
309
|
+
"""
|
|
310
|
+
1. Single well-formed compact JWS
|
|
311
|
+
2. typ="dpop+jwt", alg∈allowed, alg≠none
|
|
312
|
+
3. jwk header present & public only
|
|
313
|
+
4. Signature verifies with jwk
|
|
314
|
+
5. Validates all required claims
|
|
315
|
+
Raises InvalidDpopProofError on any failure.
|
|
316
|
+
"""
|
|
317
|
+
if not proof:
|
|
318
|
+
raise MissingRequiredArgumentError("dpop_proof")
|
|
319
|
+
if not access_token:
|
|
320
|
+
raise MissingRequiredArgumentError("access_token")
|
|
321
|
+
if not http_method or not http_url:
|
|
322
|
+
raise MissingRequiredArgumentError("http_method/http_url")
|
|
323
|
+
|
|
324
|
+
header = get_unverified_header(proof)
|
|
325
|
+
|
|
326
|
+
if header.get("typ") != "dpop+jwt":
|
|
327
|
+
raise InvalidDpopProofError("Unexpected JWT 'typ' header parameter value")
|
|
328
|
+
|
|
329
|
+
alg = header.get("alg")
|
|
330
|
+
if alg not in self._dpop_algorithms:
|
|
331
|
+
raise InvalidDpopProofError("Unsupported algorithm in DPoP proof")
|
|
332
|
+
|
|
333
|
+
jwk_dict = header.get("jwk")
|
|
334
|
+
if not jwk_dict or not isinstance(jwk_dict, dict):
|
|
335
|
+
raise InvalidDpopProofError("Missing or invalid jwk in header")
|
|
336
|
+
|
|
337
|
+
if "d" in jwk_dict:
|
|
338
|
+
raise InvalidDpopProofError("Private key material found in jwk header")
|
|
339
|
+
|
|
340
|
+
if jwk_dict.get("kty") != "EC":
|
|
341
|
+
raise InvalidDpopProofError("Only EC keys are supported for DPoP")
|
|
342
|
+
|
|
343
|
+
if jwk_dict.get("crv") != "P-256":
|
|
344
|
+
raise InvalidDpopProofError("Only P-256 curve is supported")
|
|
345
|
+
|
|
346
|
+
public_key = JsonWebKey.import_key(jwk_dict)
|
|
347
|
+
try:
|
|
348
|
+
claims = self._dpop_jwt.decode(proof, public_key)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
raise InvalidDpopProofError(f"JWT signature verification failed: {e}")
|
|
351
|
+
|
|
352
|
+
# Checks all required claims are present
|
|
353
|
+
self._validate_claims_presence(claims, ["iat", "ath", "htm", "htu", "jti"])
|
|
354
|
+
|
|
355
|
+
jti = claims["jti"]
|
|
356
|
+
|
|
357
|
+
if not isinstance(jti, str):
|
|
358
|
+
raise InvalidDpopProofError("jti claim must be a string")
|
|
359
|
+
|
|
360
|
+
if not jti.strip():
|
|
361
|
+
raise InvalidDpopProofError("jti claim must not be empty")
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
now = int(time.time())
|
|
365
|
+
iat = claims["iat"]
|
|
366
|
+
offset = getattr(self.options, "dpop_iat_offset", 300) # default 5 minutes
|
|
367
|
+
leeway = getattr(self.options, "dpop_iat_leeway", 30) # default 30 seconds
|
|
368
|
+
|
|
369
|
+
if not isinstance(iat, (int, float)):
|
|
370
|
+
raise InvalidDpopProofError("Invalid iat claim (must be integer or float)")
|
|
371
|
+
|
|
372
|
+
if iat < now - offset:
|
|
373
|
+
raise InvalidDpopProofError("DPoP Proof iat is too old")
|
|
374
|
+
elif iat > now + leeway:
|
|
375
|
+
raise InvalidDpopProofError("DPoP Proof iat is from the future")
|
|
376
|
+
|
|
377
|
+
if claims["htm"].lower() != http_method.lower():
|
|
378
|
+
raise InvalidDpopProofError("DPoP Proof htm mismatch")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
normalized_htu = normalize_url_for_htu(claims["htu"])
|
|
382
|
+
normalized_http_url = normalize_url_for_htu(http_url)
|
|
383
|
+
if normalized_htu != normalized_http_url:
|
|
384
|
+
raise InvalidDpopProofError("DPoP Proof htu mismatch")
|
|
385
|
+
except ValueError:
|
|
386
|
+
raise InvalidDpopProofError("DPoP Proof htu mismatch")
|
|
387
|
+
|
|
388
|
+
if claims["ath"] != sha256_base64url(access_token):
|
|
389
|
+
raise InvalidDpopProofError("DPoP Proof ath mismatch")
|
|
390
|
+
|
|
391
|
+
return claims
|
|
392
|
+
|
|
393
|
+
# ===== Private Methods =====
|
|
394
|
+
|
|
395
|
+
async def _discover(self) -> dict[str, Any]:
|
|
396
|
+
"""Lazy-load OIDC discovery metadata."""
|
|
397
|
+
if self._metadata is None:
|
|
398
|
+
self._metadata = await fetch_oidc_metadata(
|
|
399
|
+
domain=self.options.domain,
|
|
400
|
+
custom_fetch=self.options.custom_fetch
|
|
401
|
+
)
|
|
402
|
+
return self._metadata
|
|
403
|
+
|
|
404
|
+
async def _load_jwks(self) -> dict[str, Any]:
|
|
405
|
+
"""Fetches and caches JWKS data from the OIDC metadata."""
|
|
406
|
+
if self._jwks_data is None:
|
|
407
|
+
metadata = await self._discover()
|
|
408
|
+
jwks_uri = metadata["jwks_uri"]
|
|
409
|
+
self._jwks_data = await fetch_jwks(
|
|
410
|
+
jwks_uri=jwks_uri,
|
|
411
|
+
custom_fetch=self.options.custom_fetch
|
|
412
|
+
)
|
|
413
|
+
return self._jwks_data
|
|
414
|
+
|
|
415
|
+
def _validate_claims_presence(
|
|
416
|
+
self,
|
|
417
|
+
claims: dict[str, Any],
|
|
418
|
+
required_claims: list[str]
|
|
419
|
+
) -> None:
|
|
420
|
+
"""
|
|
421
|
+
Validates that all required claims are present in the claims dict.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
claims: The claims dictionary to validate
|
|
425
|
+
required_claims: List of claim names that must be present
|
|
426
|
+
|
|
427
|
+
Raises:
|
|
428
|
+
InvalidDpopProofError: If any required claim is missing
|
|
429
|
+
"""
|
|
430
|
+
missing_claims = []
|
|
431
|
+
|
|
432
|
+
for claim in required_claims:
|
|
433
|
+
if claim not in claims:
|
|
434
|
+
missing_claims.append(claim)
|
|
435
|
+
|
|
436
|
+
if missing_claims:
|
|
437
|
+
if len(missing_claims) == 1:
|
|
438
|
+
error_message = f"Missing required claim: {missing_claims[0]}"
|
|
439
|
+
else:
|
|
440
|
+
error_message = f"Missing required claims: {', '.join(missing_claims)}"
|
|
441
|
+
|
|
442
|
+
raise InvalidDpopProofError(error_message)
|
|
443
|
+
|
|
444
|
+
def _prepare_error(self, error: BaseAuthError, auth_scheme: Optional[str] = None) -> BaseAuthError:
|
|
445
|
+
"""
|
|
446
|
+
Prepare an error with WWW-Authenticate headers based on error type and context.
|
|
447
|
+
|
|
448
|
+
Args:
|
|
449
|
+
error: The error to prepare
|
|
450
|
+
auth_scheme: The authentication scheme that was used ("bearer" or "dpop")
|
|
451
|
+
"""
|
|
452
|
+
error_code = error.get_error_code()
|
|
453
|
+
error_description = error.get_error_description()
|
|
454
|
+
|
|
455
|
+
www_auth_headers = self._build_www_authenticate(
|
|
456
|
+
error_code=error_code,
|
|
457
|
+
error_description=error_description,
|
|
458
|
+
auth_scheme=auth_scheme
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
headers = {}
|
|
462
|
+
www_auth_values = []
|
|
463
|
+
for header_name, header_value in www_auth_headers:
|
|
464
|
+
if header_name == "WWW-Authenticate":
|
|
465
|
+
www_auth_values.append(header_value)
|
|
466
|
+
|
|
467
|
+
if www_auth_values:
|
|
468
|
+
headers["WWW-Authenticate"] = ", ".join(www_auth_values)
|
|
469
|
+
|
|
470
|
+
error._headers = headers
|
|
471
|
+
|
|
472
|
+
return error
|
|
473
|
+
|
|
474
|
+
def _build_www_authenticate(
|
|
475
|
+
self,
|
|
476
|
+
*,
|
|
477
|
+
error_code: Optional[str] = None,
|
|
478
|
+
error_description: Optional[str] = None,
|
|
479
|
+
auth_scheme: Optional[str] = None
|
|
480
|
+
) -> list[tuple[str, str]]:
|
|
481
|
+
"""
|
|
482
|
+
Returns one or two ('WWW-Authenticate', ...) tuples based on context.
|
|
483
|
+
If dpop_required mode → single DPoP challenge (with optional error params).
|
|
484
|
+
Otherwise → Bearer and/or DPoP challenges based on auth_scheme and error.
|
|
485
|
+
|
|
486
|
+
Args:
|
|
487
|
+
error_code: Error code (e.g., "invalid_token", "invalid_request")
|
|
488
|
+
error_description: Error description if any
|
|
489
|
+
auth_scheme: The authentication scheme that was used ("bearer" or "dpop")
|
|
490
|
+
"""
|
|
491
|
+
# Check if we should omit error parameters (invalid_request with empty description)
|
|
492
|
+
should_omit_error = (error_code == "invalid_request" and error_description == "")
|
|
493
|
+
|
|
494
|
+
# If DPoP is disabled, only return Bearer challenges
|
|
495
|
+
if not self.options.dpop_enabled:
|
|
496
|
+
if error_code and error_code != "unauthorized" and not should_omit_error:
|
|
497
|
+
bearer_parts = []
|
|
498
|
+
bearer_parts.append(f'error="{error_code}"')
|
|
499
|
+
if error_description:
|
|
500
|
+
bearer_parts.append(f'error_description="{error_description}"')
|
|
501
|
+
return [("WWW-Authenticate", "Bearer " + ", ".join(bearer_parts))]
|
|
502
|
+
return [("WWW-Authenticate", 'Bearer realm="api"')]
|
|
503
|
+
|
|
504
|
+
algs = " ".join(self._dpop_algorithms)
|
|
505
|
+
dpop_required = self.is_dpop_required()
|
|
506
|
+
|
|
507
|
+
# No error details or should omit error cases
|
|
508
|
+
if error_code == "unauthorized" or not error_code or should_omit_error:
|
|
509
|
+
if dpop_required:
|
|
510
|
+
return [("WWW-Authenticate", f'DPoP algs="{algs}"')]
|
|
511
|
+
return [("WWW-Authenticate", f'Bearer realm="api", DPoP algs="{algs}"')]
|
|
512
|
+
|
|
513
|
+
if dpop_required:
|
|
514
|
+
# DPoP-required mode: Single DPoP challenge with error
|
|
515
|
+
dpop_parts = []
|
|
516
|
+
if error_code and not should_omit_error:
|
|
517
|
+
dpop_parts.append(f'error="{error_code}"')
|
|
518
|
+
if error_description:
|
|
519
|
+
dpop_parts.append(f'error_description="{error_description}"')
|
|
520
|
+
dpop_parts.append(f'algs="{algs}"')
|
|
521
|
+
dpop_header = "DPoP " + ", ".join(dpop_parts)
|
|
522
|
+
return [("WWW-Authenticate", dpop_header)]
|
|
523
|
+
|
|
524
|
+
# DPoP-allowed mode: For DPoP errors, always include both challenges
|
|
525
|
+
if auth_scheme == "dpop" and error_code and not should_omit_error:
|
|
526
|
+
bearer_header = 'Bearer realm="api"'
|
|
527
|
+
dpop_parts = []
|
|
528
|
+
dpop_parts.append(f'error="{error_code}"')
|
|
529
|
+
if error_description:
|
|
530
|
+
dpop_parts.append(f'error_description="{error_description}"')
|
|
531
|
+
dpop_parts.append(f'algs="{algs}"')
|
|
532
|
+
dpop_header = "DPoP " + ", ".join(dpop_parts)
|
|
533
|
+
return [
|
|
534
|
+
("WWW-Authenticate", bearer_header),
|
|
535
|
+
("WWW-Authenticate", dpop_header),
|
|
536
|
+
]
|
|
537
|
+
|
|
538
|
+
# If auth_scheme is "bearer", include error on Bearer challenge
|
|
539
|
+
if auth_scheme == "bearer" and error_code and not should_omit_error:
|
|
540
|
+
bearer_parts = []
|
|
541
|
+
bearer_parts.append(f'error="{error_code}"')
|
|
542
|
+
if error_description:
|
|
543
|
+
bearer_parts.append(f'error_description="{error_description}"')
|
|
544
|
+
bearer_header = "Bearer " + ", ".join(bearer_parts)
|
|
545
|
+
dpop_header = f'DPoP algs="{algs}"'
|
|
546
|
+
return [("WWW-Authenticate", f'{bearer_header}, {dpop_header}')]
|
|
547
|
+
|
|
548
|
+
# Default: no error or should omit error context
|
|
549
|
+
return [
|
|
550
|
+
("WWW-Authenticate", 'Bearer realm="api"'),
|
|
551
|
+
("WWW-Authenticate", f'DPoP algs="{algs}"'),
|
|
552
|
+
]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration classes and utilities for auth0-api-python.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ApiClientOptions:
|
|
9
|
+
"""
|
|
10
|
+
Configuration for the ApiClient.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
domain: The Auth0 domain, e.g., "my-tenant.us.auth0.com".
|
|
14
|
+
audience: The expected 'aud' claim in the token.
|
|
15
|
+
custom_fetch: Optional callable that can replace the default HTTP fetch logic.
|
|
16
|
+
dpop_enabled: Whether DPoP is enabled (default: True for backward compatibility).
|
|
17
|
+
dpop_required: Whether DPoP is required (default: False, allows both Bearer and DPoP).
|
|
18
|
+
dpop_iat_leeway: Leeway in seconds for DPoP proof iat claim (default: 30).
|
|
19
|
+
dpop_iat_offset: Maximum age in seconds for DPoP proof iat claim (default: 300).
|
|
20
|
+
"""
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
domain: str,
|
|
24
|
+
audience: str,
|
|
25
|
+
custom_fetch: Optional[Callable[..., object]] = None,
|
|
26
|
+
dpop_enabled: bool = True,
|
|
27
|
+
dpop_required: bool = False,
|
|
28
|
+
dpop_iat_leeway: int = 30,
|
|
29
|
+
dpop_iat_offset: int = 300,
|
|
30
|
+
):
|
|
31
|
+
self.domain = domain
|
|
32
|
+
self.audience = audience
|
|
33
|
+
self.custom_fetch = custom_fetch
|
|
34
|
+
self.dpop_enabled = dpop_enabled
|
|
35
|
+
self.dpop_required = dpop_required
|
|
36
|
+
self.dpop_iat_leeway = dpop_iat_leeway
|
|
37
|
+
self.dpop_iat_offset = dpop_iat_offset
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Custom exceptions for auth0-api-python SDK with HTTP response metadata
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BaseAuthError(Exception):
|
|
7
|
+
"""Base class for all auth errors with HTTP response metadata."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str):
|
|
10
|
+
super().__init__(message)
|
|
11
|
+
self.message = message
|
|
12
|
+
self.name = self.__class__.__name__
|
|
13
|
+
self._headers = {} # Will be set by ApiClient._prepare_error
|
|
14
|
+
|
|
15
|
+
def get_status_code(self) -> int:
|
|
16
|
+
"""Return the HTTP status code for this error."""
|
|
17
|
+
raise NotImplementedError("Subclasses must implement get_status_code()")
|
|
18
|
+
|
|
19
|
+
def get_error_code(self) -> str:
|
|
20
|
+
"""Return the OAuth/DPoP error code."""
|
|
21
|
+
raise NotImplementedError("Subclasses must implement get_error_code()")
|
|
22
|
+
|
|
23
|
+
def get_error_description(self) -> str:
|
|
24
|
+
"""Return the error description."""
|
|
25
|
+
return self.message
|
|
26
|
+
|
|
27
|
+
def get_headers(self) -> dict[str, str]:
|
|
28
|
+
"""Return HTTP headers (including WWW-Authenticate if set)."""
|
|
29
|
+
return self._headers
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MissingRequiredArgumentError(BaseAuthError):
|
|
33
|
+
"""Error raised when a required argument is missing."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, argument: str, message: str = None):
|
|
36
|
+
if message:
|
|
37
|
+
super().__init__(message)
|
|
38
|
+
else:
|
|
39
|
+
super().__init__(f"The argument '{argument}' is required but was not provided.")
|
|
40
|
+
self.argument = argument
|
|
41
|
+
|
|
42
|
+
def get_status_code(self) -> int:
|
|
43
|
+
return 400
|
|
44
|
+
|
|
45
|
+
def get_error_code(self) -> str:
|
|
46
|
+
return "invalid_request"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VerifyAccessTokenError(BaseAuthError):
|
|
50
|
+
"""Error raised when verifying the access token fails."""
|
|
51
|
+
|
|
52
|
+
def get_status_code(self) -> int:
|
|
53
|
+
return 401
|
|
54
|
+
|
|
55
|
+
def get_error_code(self) -> str:
|
|
56
|
+
return "invalid_token"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InvalidAuthSchemeError(BaseAuthError):
|
|
60
|
+
"""Error raised when the provided authentication scheme is unsupported."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str):
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
if ":" in message and "'" in message:
|
|
65
|
+
self.scheme = message.split("'")[1]
|
|
66
|
+
else:
|
|
67
|
+
self.scheme = None
|
|
68
|
+
|
|
69
|
+
def get_status_code(self) -> int:
|
|
70
|
+
return 400
|
|
71
|
+
|
|
72
|
+
def get_error_code(self) -> str:
|
|
73
|
+
return "invalid_request"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class InvalidDpopProofError(BaseAuthError):
|
|
77
|
+
"""Error raised when validating a DPoP proof fails."""
|
|
78
|
+
|
|
79
|
+
def get_status_code(self) -> int:
|
|
80
|
+
return 400
|
|
81
|
+
|
|
82
|
+
def get_error_code(self) -> str:
|
|
83
|
+
return "invalid_dpop_proof"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class MissingAuthorizationError(BaseAuthError):
|
|
87
|
+
"""Authorization header is missing, empty, or malformed."""
|
|
88
|
+
|
|
89
|
+
def __init__(self):
|
|
90
|
+
super().__init__("")
|
|
91
|
+
|
|
92
|
+
def get_status_code(self) -> int:
|
|
93
|
+
return 400
|
|
94
|
+
|
|
95
|
+
def get_error_code(self) -> str:
|
|
96
|
+
return "invalid_request"
|