auth0-api-python 1.0.0b1__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.
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2017 Auth0, Inc. <support@auth0.com> (http://auth0.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,134 @@
1
+ Metadata-Version: 2.3
2
+ Name: auth0-api-python
3
+ Version: 1.0.0b1
4
+ Summary: SDK for verifying access tokens and securing APIs with Auth0, using Authlib.
5
+ License: MIT
6
+ Author: Snehil Kishore snehil.kishore@okta.com
7
+ Requires-Python: >=3.9,<4.0
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Dist: authlib (>=1.0,<2.0)
16
+ Requires-Dist: httpx (>=0.28.1,<0.29.0)
17
+ Requires-Dist: requests (>=2.31.0,<3.0.0)
18
+ Description-Content-Type: text/markdown
19
+
20
+ The `auth0-api-python` library allows you to secure APIs running on Python, particularly for verifying Auth0-issued access tokens.
21
+
22
+ It’s intended as a foundation for building more framework-specific integrations (e.g., with FastAPI, Django, etc.), but you can also use it directly in any Python server-side environment.
23
+
24
+ ![Release](https://img.shields.io/pypi/v/auth0-api-python) ![Downloads](https://img.shields.io/pypi/dw/auth0-api-python) [![License](https://img.shields.io/:license-MIT-blue.svg?style=flat)](https://opensource.org/licenses/MIT)
25
+
26
+ 📚 [Documentation](#documentation) - 🚀 [Getting Started](#getting-started) - 💬 [Feedback](#feedback)
27
+
28
+ ## Documentation
29
+
30
+ - [Docs Site](https://auth0.com/docs) - explore our docs site and learn more about Auth0.
31
+
32
+ ## Getting Started
33
+
34
+ ### 1. Install the SDK
35
+
36
+ _This library requires Python 3.9+._
37
+
38
+ ```shell
39
+ pip install auth0-api-python
40
+ ```
41
+
42
+ If you’re using Poetry:
43
+
44
+ ```shell
45
+ poetry install auth0-api-python
46
+ ```
47
+
48
+ ### 2. Create the Auth0 SDK client
49
+
50
+ Create an instance of the `ApiClient`. This instance will be imported and used anywhere we need access to the methods.
51
+
52
+ ```python
53
+ from auth0_api_python import ApiClient, ApiClientOptions
54
+
55
+
56
+ api_client = ApiClient(ApiClientOptions(
57
+ domain="<AUTH0_DOMAIN>",
58
+ audience="<AUTH0_AUDIENCE>"
59
+ ))
60
+ ```
61
+
62
+ - The `AUTH0_DOMAIN` can be obtained from the [Auth0 Dashboard](https://manage.auth0.com) once you've created an application.
63
+ - The `AUTH0_AUDIENCE` is the identifier of the API. You can find this in the [APIs section of the Auth0 Dashboard](https://manage.auth0.com/#/apis/).
64
+
65
+ ### 3. Verify the Access Token
66
+
67
+ Use the `verify_access_token` method to validate access tokens. The method automatically checks critical claims like `iss`, `aud`, `exp`, `nbf`.
68
+
69
+ ```python
70
+ import asyncio
71
+
72
+ from auth0_api_python import ApiClient, ApiClientOptions
73
+
74
+ async def main():
75
+ api_client = ApiClient(ApiClientOptions(
76
+ domain="<AUTH0_DOMAIN>",
77
+ audience="<AUTH0_AUDIENCE>"
78
+ ))
79
+ access_token = "..."
80
+
81
+ decoded_and_verified_token = await api_client.verify_access_token(access_token=access_token)
82
+ print(decoded_and_verified_token)
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ In this example, the returned dictionary contains the decoded claims (like `sub`, `scope`, etc.) from the verified token.
88
+
89
+ #### Requiring Additional Claims
90
+
91
+ If your application demands extra claims, specify them with `required_claims`:
92
+
93
+ ```python
94
+ decoded_and_verified_token = await api_client.verify_access_token(
95
+ access_token=access_token,
96
+ required_claims=["my_custom_claim"]
97
+ )
98
+ ```
99
+
100
+ If the token lacks `my_custom_claim` or fails any standard check (issuer mismatch, expired token, invalid signature), the method raises a `VerifyAccessTokenError`.
101
+
102
+ ## Feedback
103
+
104
+ ### Contributing
105
+
106
+ We appreciate feedback and contribution to this repo! Before you get started, please read the following:
107
+
108
+ - [Auth0's general contribution guidelines](https://github.com/auth0/open-source-template/blob/master/GENERAL-CONTRIBUTING.md)
109
+ - [Auth0's code of conduct guidelines](https://github.com/auth0/open-source-template/blob/master/CODE-OF-CONDUCT.md)
110
+ - [This repo's contribution guide](./../../CONTRIBUTING.md)
111
+
112
+ ### Raise an issue
113
+
114
+ To provide feedback or report a bug, please [raise an issue on our issue tracker](https://github.com/auth0/auth0-server-python/issues).
115
+
116
+ ## Vulnerability Reporting
117
+
118
+ Please do not report security vulnerabilities on the public GitHub issue tracker. The [Responsible Disclosure Program](https://auth0.com/responsible-disclosure-policy) details the procedure for disclosing security issues.
119
+
120
+ ## What is Auth0?
121
+
122
+ <p align="center">
123
+ <picture>
124
+ <source media="(prefers-color-scheme: dark)" srcset="https://cdn.auth0.com/website/sdks/logos/auth0_dark_mode.png" width="150">
125
+ <source media="(prefers-color-scheme: light)" srcset="https://cdn.auth0.com/website/sdks/logos/auth0_light_mode.png" width="150">
126
+ <img alt="Auth0 Logo" src="https://cdn.auth0.com/website/sdks/logos/auth0_light_mode.png" width="150">
127
+ </picture>
128
+ </p>
129
+ <p align="center">
130
+ Auth0 is an easy to implement, adaptable authentication and authorization platform. To learn more checkout <a href="https://auth0.com/why-auth0">Why Auth0?</a>
131
+ </p>
132
+ <p align="center">
133
+ This project is licensed under the MIT license. See the <a href="https://github.com/auth0/auth0-server-python/blob/main/packages/auth0_api_python/LICENSE"> LICENSE</a> file for more info.
134
+ </p>
@@ -0,0 +1,10 @@
1
+ src/__init__.py,sha256=CTj82tGIXF0ypzAjMeCZ3cuAfKF472ZsZ0bdorGPh7E,291
2
+ src/api_client.py,sha256=59_aYUMXGv1JTJosDjZnOteLeEmT_XPX589FzG5Gnsk,4578
3
+ src/config.py,sha256=CjqJ1nTO1XT9bJZgoLUbCGp3SSFkKYItoV1eE9V_Gr4,651
4
+ src/errors.py,sha256=PG5NSZqTR8JJhFtWsxZPQwC-BNmKhtFwvCS1vE3Dcc0,671
5
+ src/token_utils.py,sha256=2sa0m4y8Mgnh2mIbI0-VN-UUUTysYmS1Gk255xpeUHc,3817
6
+ src/utils.py,sha256=D38wC2Wan3fMF25TqqTZzyrRo0EUkk3XDCX-ADTQQJo,2905
7
+ auth0_api_python-1.0.0b1.dist-info/LICENSE,sha256=EEi9tibVAgKdGU1DmXmHjZFOwMfYXDV45mabfEmULkQ,1116
8
+ auth0_api_python-1.0.0b1.dist-info/METADATA,sha256=ZhXRmHrcyXStcXjr8rSKZrjO2s0RdvpJ4LHfeS_bFu8,5167
9
+ auth0_api_python-1.0.0b1.dist-info/WHEEL,sha256=IYZQI976HJqqOpQU6PHkJ8fb3tMNBFjg-Cn-pwAbaFM,88
10
+ auth0_api_python-1.0.0b1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.0.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
src/__init__.py ADDED
@@ -0,0 +1,14 @@
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 .config import ApiClientOptions
10
+
11
+ __all__ = [
12
+ "ApiClient",
13
+ "ApiClientOptions"
14
+ ]
src/api_client.py ADDED
@@ -0,0 +1,128 @@
1
+ import time
2
+ from typing import Optional, List, Dict, Any
3
+
4
+ from authlib.jose import JsonWebToken, JsonWebKey
5
+
6
+ from .config import ApiClientOptions
7
+ from .errors import MissingRequiredArgumentError, VerifyAccessTokenError
8
+ from .utils import fetch_oidc_metadata, fetch_jwks, get_unverified_header
9
+
10
+ class ApiClient:
11
+ """
12
+ The main class for discovering OIDC metadata (issuer, jwks_uri) and verifying
13
+ Auth0-issued JWT access tokens in an async environment.
14
+ """
15
+
16
+ def __init__(self, options: ApiClientOptions):
17
+
18
+ if not options.domain:
19
+ raise MissingRequiredArgumentError("domain")
20
+ if not options.audience:
21
+ raise MissingRequiredArgumentError("audience")
22
+
23
+ self.options = options
24
+ self._metadata: Optional[Dict[str, Any]] = None
25
+ self._jwks_data: Optional[Dict[str, Any]] = None
26
+
27
+ self._jwt = JsonWebToken(["RS256"])
28
+
29
+ async def _discover(self) -> Dict[str, Any]:
30
+ """Lazy-load OIDC discovery metadata."""
31
+ if self._metadata is None:
32
+ self._metadata = await fetch_oidc_metadata(
33
+ domain=self.options.domain,
34
+ custom_fetch=self.options.custom_fetch
35
+ )
36
+ return self._metadata
37
+
38
+ async def _load_jwks(self) -> Dict[str, Any]:
39
+ """Fetches and caches JWKS data from the OIDC metadata."""
40
+ if self._jwks_data is None:
41
+ metadata = await self._discover()
42
+ jwks_uri = metadata["jwks_uri"]
43
+ self._jwks_data = await fetch_jwks(
44
+ jwks_uri=jwks_uri,
45
+ custom_fetch=self.options.custom_fetch
46
+ )
47
+ return self._jwks_data
48
+
49
+ async def verify_access_token(
50
+ self,
51
+ access_token: str,
52
+ required_claims: Optional[List[str]] = None
53
+ ) -> Dict[str, Any]:
54
+ """
55
+ Asynchronously verifies the provided JWT access token.
56
+
57
+ - Fetches OIDC metadata and JWKS if not already cached.
58
+ - Decodes and validates signature (RS256) with the correct key.
59
+ - Checks standard claims: 'iss', 'aud', 'exp', 'iat'
60
+ - Checks extra required claims if 'required_claims' is provided.
61
+
62
+ Returns:
63
+ The decoded token claims if valid.
64
+
65
+ Raises:
66
+ MissingRequiredArgumentError: If no token is provided.
67
+ VerifyAccessTokenError: If verification fails (signature, claims mismatch, etc.).
68
+ """
69
+ if not access_token:
70
+ raise MissingRequiredArgumentError("access_token")
71
+
72
+ required_claims = required_claims or []
73
+
74
+
75
+ try:
76
+ header = await get_unverified_header(access_token)
77
+ kid = header["kid"]
78
+ except Exception as e:
79
+ raise VerifyAccessTokenError(f"Failed to parse token header: {str(e)}") from e
80
+
81
+ jwks_data = await self._load_jwks()
82
+ matching_key_dict = None
83
+ for key_dict in jwks_data["keys"]:
84
+ if key_dict.get("kid") == kid:
85
+ matching_key_dict = key_dict
86
+ break
87
+
88
+ if not matching_key_dict:
89
+ raise VerifyAccessTokenError(f"No matching key found for kid: {kid}")
90
+
91
+ public_key = JsonWebKey.import_key(matching_key_dict)
92
+
93
+ if isinstance(access_token, str) and access_token.startswith("b'"):
94
+ access_token = access_token[2:-1]
95
+ try:
96
+ claims = self._jwt.decode(access_token, public_key)
97
+ except Exception as e:
98
+ raise VerifyAccessTokenError(f"Signature verification failed: {str(e)}") from e
99
+
100
+ metadata = await self._discover()
101
+ issuer = metadata["issuer"]
102
+
103
+
104
+ if claims.get("iss") != issuer:
105
+ raise VerifyAccessTokenError("Issuer mismatch")
106
+
107
+ expected_aud = self.options.audience
108
+ actual_aud = claims.get("aud")
109
+
110
+ if isinstance(actual_aud, list):
111
+ if expected_aud not in actual_aud:
112
+ raise VerifyAccessTokenError("Audience mismatch (not in token's aud array)")
113
+ else:
114
+ if actual_aud != expected_aud:
115
+ raise VerifyAccessTokenError("Audience mismatch (single aud)")
116
+
117
+ now = int(time.time())
118
+ if "exp" not in claims or now >= claims["exp"]:
119
+ raise VerifyAccessTokenError("Token is expired")
120
+ if "iat" not in claims:
121
+ raise VerifyAccessTokenError("Missing 'iat' claim in token")
122
+
123
+ #Additional required_claims
124
+ for rc in required_claims:
125
+ if rc not in claims:
126
+ raise VerifyAccessTokenError(f"Missing required claim: {rc}")
127
+
128
+ return claims
src/config.py ADDED
@@ -0,0 +1,24 @@
1
+ """
2
+ Configuration classes and utilities for auth0-api-python.
3
+ """
4
+
5
+ from typing import Optional, Callable
6
+
7
+ class ApiClientOptions:
8
+ """
9
+ Configuration for the ApiClient.
10
+
11
+ Args:
12
+ domain: The Auth0 domain, e.g., "my-tenant.us.auth0.com".
13
+ audience: The expected 'aud' claim in the token.
14
+ custom_fetch: Optional callable that can replace the default HTTP fetch logic.
15
+ """
16
+ def __init__(
17
+ self,
18
+ domain: str,
19
+ audience: str,
20
+ custom_fetch: Optional[Callable[..., object]] = None
21
+ ):
22
+ self.domain = domain
23
+ self.audience = audience
24
+ self.custom_fetch = custom_fetch
src/errors.py ADDED
@@ -0,0 +1,21 @@
1
+ """
2
+ Custom exceptions for auth0-api-python SDK
3
+ """
4
+
5
+ class MissingRequiredArgumentError(Exception):
6
+ """Error raised when a required argument is missing."""
7
+ code = "missing_required_argument_error"
8
+
9
+ def __init__(self, argument: str):
10
+ super().__init__(f"The argument '{argument}' is required but was not provided.")
11
+ self.argument = argument
12
+ self.name = self.__class__.__name__
13
+
14
+
15
+ class VerifyAccessTokenError(Exception):
16
+ """Error raised when verifying the access token fails."""
17
+ code = "verify_access_token_error"
18
+
19
+ def __init__(self, message: str):
20
+ super().__init__(message)
21
+ self.name = self.__class__.__name__
src/token_utils.py ADDED
@@ -0,0 +1,84 @@
1
+ import time
2
+ from typing import Optional, Dict, Any, Union
3
+ from authlib.jose import JsonWebKey, jwt
4
+
5
+
6
+ # A private RSA JWK for test usage.
7
+
8
+ PRIVATE_JWK = {
9
+ "kty": "RSA",
10
+ "alg": "RS256",
11
+ "use": "sig",
12
+ "kid": "TEST_KEY",
13
+ "n": "whYOFK2Ocbbpb_zVypi9SeKiNUqKQH0zTKN1-6fpCTu6ZalGI82s7XK3tan4dJt90ptUPKD2zvxqTzFNfx4HHHsrYCf2-FMLn1VTJfQazA2BvJqAwcpW1bqRUEty8tS_Yv4hRvWfQPcc2Gc3-_fQOOW57zVy-rNoJc744kb30NjQxdGp03J2S3GLQu7oKtSDDPooQHD38PEMNnITf0pj-KgDPjymkMGoJlO3aKppsjfbt_AH6GGdRghYRLOUwQU-h-ofWHR3lbYiKtXPn5dN24kiHy61e3VAQ9_YAZlwXC_99GGtw_NpghFAuM4P1JDn0DppJldy3PGFC0GfBCZASw",
14
+ "e": "AQAB",
15
+ "d": "VuVE_KEP6323WjpbBdAIv7HGahGrgGANvbxZsIhm34lsVOPK0XDegZkhAybMZHjRhp-gwVxX5ChC-J3cUpOBH5FNxElgW6HizD2Jcq6t6LoLYgPSrfEHm71iHg8JsgrqfUnGYFzMJmv88C6WdCtpgG_qJV1K00_Ly1G1QKoBffEs-v4fAMJrCbUdCz1qWto-PU-HLMEo-krfEpGgcmtZeRlDADh8cETMQlgQfQX2VWq_aAP4a1SXmo-j0cvRU4W5Fj0RVwNesIpetX2ZFz4p_JmB5sWFEj_fC7h5z2lq-6Bme2T3BHtXkIxoBW0_pYVnASC8P2puO5FnVxDmWuHDYQ",
16
+ "p": "07rgXd_tLUhVRF_g1OaqRZh5uZ8hiLWUSU0vu9coOaQcatSqjQlIwLW8UdKv_38GrmpIfgcEVQjzq6rFBowUm9zWBO9Eq6enpasYJBOeD8EMeDK-nsST57HjPVOCvoVC5ZX-cozPXna3iRNZ1TVYBY3smn0IaxysIK-zxESf4pM",
17
+ "q": "6qrE9TPhCS5iNR7QrKThunLu6t4H_8CkYRPLbvOIt2MgZyPLiZCsvdkTVSOX76QQEXt7Y0nTNua69q3K3Jhf-YOkPSJsWTxgrfOnjoDvRKzbW3OExIMm7D99fVBODuNWinjYgUwGSqGAsb_3TKhtI-Gr5ls3fn6B6oEjVL0dpmk",
18
+ "dp": "mHqjrFdgelT2OyiFRS3dAAPf3cLxJoAGC4gP0UoQyPocEP-Y17sQ7t-ygIanguubBy65iDFLeGXa_g0cmSt2iAzRAHrDzI8P1-pQl2KdWSEg9ssspjBRh_F_AiJLLSPRWn_b3-jySkhawtfxwO8Kte1QsK1My765Y0zFvJnjPws",
19
+ "dq": "KmjaV4YcsVAUp4z-IXVa5htHWmLuByaFjpXJOjABEUN0467wZdgjn9vPRp-8Ia8AyGgMkJES_uUL_PDDrMJM9gb4c6P4-NeUkVtreLGMjFjA-_IQmIMrUZ7XywHsWXx0c2oLlrJqoKo3W-hZhR0bPFTYgDUT_mRWjk7wV6wl46E",
20
+ "qi": "iYltkV_4PmQDfZfGFpzn2UtYEKyhy-9t3Vy8Mw2VHLAADKGwJvVK5ficQAr2atIF1-agXY2bd6KV-w52zR8rmZfTr0gobzYIyqHczOm13t7uXJv2WygY7QEC2OGjdxa2Fr9RnvS99ozMa5nomZBqTqT7z5QV33czjPRCjvg6FcE",
21
+ }
22
+
23
+
24
+ async def generate_token(
25
+ domain: str,
26
+ user_id: str,
27
+ audience: Optional[str] = None,
28
+ issuer: Union[str, bool, None] = None,
29
+ iat: bool = True,
30
+ exp: bool = True,
31
+ claims: Optional[Dict[str, Any]] = None,
32
+ expiration_time: int = 3600,
33
+ ) -> str:
34
+ """
35
+ Generates a real RS256-signed JWT using the private key above.
36
+
37
+ Args:
38
+ domain: The Auth0 domain (used if issuer is not False).
39
+ user_id: The 'sub' claim in the token.
40
+ audience: The 'aud' claim in the token. If omitted, 'aud' won't be included.
41
+ issuer:
42
+ - If a string, it's placed in 'iss' claim.
43
+ - If None, default is f"https://{domain}/".
44
+ - If False, skip 'iss' claim entirely.
45
+ iat: Whether to set the 'iat' (issued at) claim. If False, skip it.
46
+ exp: Whether to set the 'exp' claim. If False, skip it.
47
+ claims: Additional custom claims to merge into the token.
48
+ expiration_time: If exp is True, how many seconds from now until expiration.
49
+
50
+ Returns:
51
+ A RS256-signed JWT string.
52
+
53
+ Example usage:
54
+ token = generate_token(
55
+ domain="example.us.auth0.com",
56
+ user_id="user123",
57
+ audience="my-api",
58
+ issuer=False,
59
+ iat=False,
60
+ exp=False,
61
+ claims={"scope": "read:stuff"}
62
+ )
63
+ """
64
+ token_claims = dict(claims or {})
65
+ token_claims.setdefault("sub", user_id)
66
+
67
+ if iat:
68
+ token_claims["iat"] = int(time.time())
69
+
70
+ if exp:
71
+ token_claims["exp"] = int(time.time()) + expiration_time
72
+
73
+ if issuer is not False:
74
+ token_claims["iss"] = issuer if isinstance(issuer, str) else f"https://{domain}/"
75
+
76
+ if audience:
77
+ token_claims["aud"] = audience
78
+
79
+
80
+ key = JsonWebKey.import_key(PRIVATE_JWK)
81
+
82
+ header = {"alg": "RS256", "kid": PRIVATE_JWK["kid"]}
83
+ token = jwt.encode(header, token_claims, key)
84
+ return token
src/utils.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ Utility functions for OIDC discovery and JWKS fetching (asynchronously)
3
+ using httpx or a custom fetch approach.
4
+ """
5
+
6
+ import httpx
7
+ import base64
8
+ import json
9
+ from typing import Any, Dict, Optional, Callable, Union
10
+
11
+ async def fetch_oidc_metadata(
12
+ domain: str,
13
+ custom_fetch: Optional[Callable[..., Any]] = None
14
+ ) -> Dict[str, Any]:
15
+ """
16
+ Asynchronously fetch the OIDC config from https://{domain}/.well-known/openid-configuration.
17
+ Returns a dict with keys like issuer, jwks_uri, authorization_endpoint, etc.
18
+ If custom_fetch is provided, we call it instead of httpx.
19
+ """
20
+ url = f"https://{domain}/.well-known/openid-configuration"
21
+ if custom_fetch:
22
+ response = await custom_fetch(url)
23
+ return response.json() if hasattr(response, "json") else response
24
+ else:
25
+ async with httpx.AsyncClient() as client:
26
+ resp = await client.get(url)
27
+ resp.raise_for_status()
28
+ return resp.json()
29
+
30
+
31
+ async def fetch_jwks(
32
+ jwks_uri: str,
33
+ custom_fetch: Optional[Callable[..., Any]] = None
34
+ ) -> Dict[str, Any]:
35
+ """
36
+ Asynchronously fetch the JSON Web Key Set from jwks_uri.
37
+ Returns the raw JWKS JSON, e.g. {'keys': [...]}
38
+
39
+ If custom_fetch is provided, it must be an async callable
40
+ that fetches data from the jwks_uri.
41
+ """
42
+ if custom_fetch:
43
+ response = await custom_fetch(jwks_uri)
44
+ return response.json() if hasattr(response, "json") else response
45
+ else:
46
+ async with httpx.AsyncClient() as client:
47
+ resp = await client.get(jwks_uri)
48
+ resp.raise_for_status()
49
+ return resp.json()
50
+
51
+
52
+ async def get_unverified_header(token: Union[str, bytes]) -> dict:
53
+ """
54
+ Parse the first segment (header) of a JWT without verifying signature.
55
+ Ensures correct Base64 padding before decode to avoid garbage bytes.
56
+ """
57
+ if isinstance(token, bytes):
58
+ token = token.decode("utf-8")
59
+ try:
60
+ header_b64, _, _ = token.split(".", 2)
61
+ except ValueError:
62
+ raise ValueError("Not enough segments in token")
63
+
64
+ header_b64 = remove_bytes_prefix(header_b64)
65
+
66
+ header_b64 = fix_base64_padding(header_b64)
67
+
68
+ header_data = base64.urlsafe_b64decode(header_b64)
69
+ return json.loads(header_data)
70
+
71
+
72
+
73
+ def fix_base64_padding(segment: str) -> str:
74
+ """
75
+ If `segment`'s length is not a multiple of 4, add '=' padding
76
+ so that base64.urlsafe_b64decode won't produce nonsense bytes.
77
+ No extra '=' added if length is already a multiple of 4.
78
+ """
79
+ remainder = len(segment) % 4
80
+ if remainder == 0:
81
+ return segment # No additional padding needed
82
+ return segment + ("=" * (4 - remainder))
83
+
84
+ def remove_bytes_prefix(s: str) -> str:
85
+ """If the string looks like b'eyJh...', remove the leading b' and trailing '."""
86
+ if s.startswith("b'"):
87
+ return s[2:] # cut off the leading b'
88
+ return s