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.
@@ -11,4 +11,4 @@ from .config import ApiClientOptions
11
11
  __all__ = [
12
12
  "ApiClient",
13
13
  "ApiClientOptions"
14
- ]
14
+ ]
@@ -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"