jwks-multi 0.3.1__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.
jwks_multi/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .jwks_multi_verifier import decode_token, get_public_keys
2
+
3
+ __all__ = [
4
+ 'decode_token',
5
+ 'get_public_keys',
6
+ ]
File without changes
@@ -0,0 +1,29 @@
1
+ import time
2
+
3
+ from authlib.jose import JWTClaims
4
+
5
+
6
+ class ExtendedJWTClaims(JWTClaims):
7
+ def __init__(self, *args: list, **kwargs: dict) -> None:
8
+ super().__init__(*args, **kwargs)
9
+ self.claims_with_expiration = ['exp', 'nbf', 'iat']
10
+
11
+ def validate(
12
+ self,
13
+ now: float | None = None,
14
+ leeway: float = 0,
15
+ ) -> None:
16
+ if now is None:
17
+ now = float(time.time())
18
+ self._validate_essential_claims()
19
+ for key, values in self.options.items():
20
+ if key not in self.REGISTERED_CLAIMS:
21
+ self._validate_claim_value(key)
22
+ continue
23
+ if values.get('verify') is False:
24
+ continue
25
+ validation = getattr(self, f'validate_{key}')
26
+ if key in self.claims_with_expiration:
27
+ validation(now, leeway)
28
+ else:
29
+ validation()
@@ -0,0 +1,174 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from typing import Any
5
+
6
+ import httpx
7
+ from authlib.common.errors import AuthlibBaseError
8
+ from authlib.jose import JsonWebKey, JWTClaims, KeySet, jwt
9
+
10
+ from jwks_multi.extentions.jwt_clains import ExtendedJWTClaims
11
+
12
+ logger = logging.getLogger('jwks_multi')
13
+
14
+ _jwk_set: dict[str, dict[str, list[Any] | float]] = {}
15
+ _jwk_locks: dict[str, asyncio.Lock] = {}
16
+
17
+
18
+ async def get_public_keys(
19
+ jwks_urls: list[str],
20
+ pre_public_keys: dict[str, Any],
21
+ *,
22
+ cache_jwk_set: bool = True,
23
+ jwks_ttl: float | None = None,
24
+ jwks_timeout: float = 5.0,
25
+ ) -> KeySet:
26
+ if pre_public_keys:
27
+ _add_keys_from_jwks(
28
+ uri='localhost',
29
+ jwks=pre_public_keys,
30
+ )
31
+ await _get_remote_jwks(
32
+ jwks_urls,
33
+ jwks_timeout,
34
+ jwks_ttl,
35
+ cache_jwk_set=cache_jwk_set,
36
+ )
37
+ return _get_jwks_set()
38
+
39
+
40
+ async def decode_token(
41
+ token: str,
42
+ key: KeySet,
43
+ options: dict[str, bool] | None = None,
44
+ issuers: list[str] | None = None,
45
+ audiences: list[str] | None = None,
46
+ ) -> JWTClaims:
47
+ if not options:
48
+ options = {}
49
+ claims = jwt.decode(
50
+ s=token,
51
+ key=key,
52
+ claims_cls=ExtendedJWTClaims,
53
+ claims_options={
54
+ 'iss': {
55
+ 'essential': True,
56
+ 'verify': bool(issuers),
57
+ 'values': issuers,
58
+ },
59
+ 'aud': {
60
+ 'essential': True,
61
+ 'verify': bool(audiences),
62
+ 'values': audiences,
63
+ },
64
+ 'sub': {
65
+ 'essential': True,
66
+ 'verify': options.get('verify_sub', True),
67
+ },
68
+ 'exp': {
69
+ 'essential': False,
70
+ 'verify': options.get('verify_exp', True),
71
+ },
72
+ 'nbf': {
73
+ 'essential': False,
74
+ 'verify': options.get('verify_nbf', True),
75
+ },
76
+ 'iat': {
77
+ 'essential': True,
78
+ 'verify': options.get('verify_iat', True),
79
+ },
80
+ 'jti': {
81
+ 'essential': True,
82
+ 'verify': options.get('verify_jti', True),
83
+ },
84
+ },
85
+ )
86
+ claims.validate(leeway=options.get('leeway') or 0)
87
+ return claims
88
+
89
+
90
+ def _get_jwks_set() -> KeySet:
91
+ jwks_set = []
92
+ for jwk in _jwk_set.values():
93
+ jwks_set.extend(jwk['keys'])
94
+ if not jwks_set:
95
+ raise AuthlibBaseError(
96
+ error='missing_keys',
97
+ description=(
98
+ 'No JWKS keys available. '
99
+ 'Verify the provided URLs and pre_public_keys.'
100
+ ),
101
+ )
102
+ return KeySet(jwks_set)
103
+
104
+
105
+ def _add_keys_from_jwks(
106
+ uri: str,
107
+ jwks: dict[str, list[dict[str, Any]]],
108
+ expires_at: float = -1,
109
+ ) -> None:
110
+ if jwks and 'keys' in jwks:
111
+ key_set = JsonWebKey.import_key_set(jwks)
112
+ _jwk_set[uri] = {'keys': key_set.keys, 'expires_at': expires_at}
113
+
114
+
115
+ def _is_cached(now: float, uri: str, *, cache_jwk_set: bool) -> bool:
116
+ if not cache_jwk_set:
117
+ return False
118
+ jwks_data = _jwk_set.get(uri)
119
+ if not jwks_data:
120
+ return False
121
+ expires = jwks_data.get('expires_at') or -1
122
+ return expires == -1 or now < expires
123
+
124
+
125
+ def _get_uri_lock(uri: str) -> asyncio.Lock:
126
+ lock = _jwk_locks.get(uri)
127
+ if lock is None:
128
+ lock = asyncio.Lock()
129
+ _jwk_locks[uri] = lock
130
+ return lock
131
+
132
+
133
+ async def _get_remote_jwks(
134
+ jwks_urls: list[str],
135
+ jwks_timeout: float = 5.0,
136
+ jwks_ttl: float | None = None,
137
+ *,
138
+ cache_jwk_set: bool = True,
139
+ ) -> dict[str, dict[str, list[Any] | float]]:
140
+ try:
141
+ async with httpx.AsyncClient(timeout=jwks_timeout) as client:
142
+ for uri in jwks_urls:
143
+ if uri == 'localhost':
144
+ continue
145
+ if _is_cached(time.time(), uri, cache_jwk_set=cache_jwk_set):
146
+ continue
147
+ async with _get_uri_lock(uri):
148
+ if _is_cached(
149
+ time.time(),
150
+ uri,
151
+ cache_jwk_set=cache_jwk_set,
152
+ ):
153
+ continue
154
+ logger.info(
155
+ '[ExtendedPyJWKClient] Fetching data from %s',
156
+ uri,
157
+ )
158
+ response = await client.get(uri)
159
+ response.raise_for_status()
160
+ jwks_data = response.json()
161
+
162
+ expires_at = -1
163
+ if jwks_ttl:
164
+ expires_at = time.time() + jwks_ttl
165
+ _add_keys_from_jwks(
166
+ uri=uri,
167
+ jwks=jwks_data,
168
+ expires_at=expires_at,
169
+ )
170
+
171
+ except httpx.HTTPError:
172
+ logger.exception('Fail to fetch data from the url %s', uri)
173
+
174
+ return _jwk_set
@@ -0,0 +1,188 @@
1
+ Metadata-Version: 2.4
2
+ Name: jwks-multi
3
+ Version: 0.3.1
4
+ Summary: JWT verifier with support for multiple JWKS endpoints and local fallback keys
5
+ Author: Squad Risco Transacional
6
+ License-Expression: MIT
7
+ Project-URL: Repository, https://gitlab.luizalabs.com/luizalabs/risco-360/libs/jwks-multi
8
+ Project-URL: Issues, https://gitlab.luizalabs.com/luizalabs/risco-360/libs/jwks-multi/-/issues
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3 :: Only
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Topic :: Security
13
+ Requires-Python: >=3.13
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE
16
+ Requires-Dist: authlib>=1.6.9
17
+ Requires-Dist: httpx>=0.28.1
18
+ Dynamic: license-file
19
+
20
+ # JWKs Multi Verifier
21
+
22
+ Biblioteca Python para validacao de JWT com suporte a multiplas fontes JWKS, cache e chaves locais de fallback.
23
+
24
+ API publica principal:
25
+
26
+ - `from jwks_multi import get_public_keys, decode_token`
27
+
28
+ ## Instalacao via URL (Git)
29
+
30
+ ### pip
31
+
32
+ ```bash
33
+ pip install "git+ssh://git@<repo>/jwks-multi.git"
34
+ pip install "git+ssh://git@<repo>/jwks-multi.git@main"
35
+ pip install "git+ssh://git@<repo>/jwks-multi.git@v<tag>"
36
+ pip install "git+ssh://git@<repo>/jwks-multi.git@<commit_sha>"
37
+ ```
38
+
39
+ ## Uso
40
+
41
+ ### Fluxo basico
42
+
43
+ ```python
44
+ import asyncio
45
+
46
+ from jwks_multi import get_public_keys, decode_token
47
+
48
+
49
+ async def main() -> None:
50
+ key_set = await get_public_keys(
51
+ jwks_urls=["https://idp.exemplo.com/.well-known/jwks.json"],
52
+ pre_public_keys={},
53
+ )
54
+
55
+ claims = await decode_token(
56
+ token="<jwt>",
57
+ key=key_set,
58
+ options={},
59
+ issuers=["https://idp.exemplo.com/"],
60
+ audiences=["minha-api"],
61
+ )
62
+
63
+ print(claims)
64
+
65
+
66
+ asyncio.run(main())
67
+ ```
68
+
69
+ ### Parametros disponiveis
70
+
71
+ #### `get_public_keys(...)`
72
+
73
+ Retorna um `KeySet` com a uniao de chaves locais (`pre_public_keys`) e chaves remotas (`jwks_urls`).
74
+
75
+ | Parametro | Tipo | Padrao | Descricao |
76
+ | --- | --- | --- | --- |
77
+ | `jwks_urls` | `list[str]` | obrigatorio | Lista de endpoints JWKS remotos. |
78
+ | `pre_public_keys` | `dict[str, Any]` | obrigatorio | JWKS local no formato `{"keys": [...]}` para fallback. |
79
+ | `cache_jwk_set` | `bool` | `True` | Habilita/desabilita cache em memoria por URI. |
80
+ | `jwks_ttl` | `int \| float \| None` | `None` | Tempo de vida (segundos) do cache remoto. `None` significa sem expiracao. |
81
+ | `jwks_timeout` | `int \| float` | `5.0` | Timeout (segundos) das requisicoes HTTP para JWKS remoto. |
82
+
83
+ #### `decode_token(...)`
84
+
85
+ Decodifica e valida o token com `ExtendedJWTClaims`.
86
+
87
+ | Parametro | Tipo | Padrao | Descricao |
88
+ | --- | --- | --- | --- |
89
+ | `token` | `str` | obrigatorio | JWT serializado. |
90
+ | `key` | `KeySet` | obrigatorio | Chaves retornadas por `get_public_keys(...)`. |
91
+ | `options` | `dict[str, bool \| float] \| None` | `None` | Controle fino das validacoes (`verify_sub`, `verify_exp`, `verify_nbf`, `verify_iat`, `verify_jti`). Inclui `leeway` (folga em segundos para validacao temporal). |
92
+ | `issuers` | `list[str] \| None` | `None` | Lista de emissores aceitos para claim `iss`. |
93
+ | `audiences` | `list[str] \| None` | `None` | Lista de audiencias aceitas para claim `aud`. |
94
+
95
+ ### Exemplo avancado
96
+
97
+ ```python
98
+ import asyncio
99
+
100
+ from jwks_multi import get_public_keys, decode_token
101
+
102
+
103
+ async def validate(token: str) -> dict:
104
+ key_set = await get_public_keys(
105
+ jwks_urls=[
106
+ "https://idp-a.exemplo.com/.well-known/jwks.json",
107
+ "https://idp-b.exemplo.com/.well-known/jwks.json",
108
+ ],
109
+ pre_public_keys={
110
+ "keys": [
111
+ {
112
+ "kty": "RSA",
113
+ "kid": "local-fallback-kid",
114
+ "use": "sig",
115
+ "alg": "RS256",
116
+ "n": "<modulus>",
117
+ "e": "AQAB",
118
+ }
119
+ ]
120
+ },
121
+ cache_jwk_set=True,
122
+ jwks_ttl=300,
123
+ jwks_timeout=2.5,
124
+ )
125
+
126
+ claims = await decode_token(
127
+ token=token,
128
+ key=key_set,
129
+ options={
130
+ "verify_sub": True,
131
+ "verify_exp": True,
132
+ "verify_nbf": True,
133
+ "verify_iat": True,
134
+ "verify_jti": True,
135
+ "leeway": 5,
136
+ },
137
+ issuers=["https://idp-a.exemplo.com/", "https://idp-b.exemplo.com/"],
138
+ audiences=["api-interna"],
139
+ )
140
+
141
+ return dict(claims)
142
+
143
+
144
+ # asyncio.run(validate("<jwt>"))
145
+ ```
146
+
147
+ ### Observacoes importantes
148
+
149
+ - O cache e mantido em memoria no processo e separado por URI de JWKS.
150
+ - Quando `jwks_ttl=None`, as chaves remotas nao expiram (ate reinicio do processo).
151
+ - URLs com valor literal `"localhost"` sao tratadas como pseudo-origem local e nao geram chamada HTTP.
152
+
153
+ ## Desenvolvimento local
154
+
155
+ ```bash
156
+ pip install -e .
157
+ python -c "from jwks_multi import get_public_keys, decode_token; print('API loaded successfully')"
158
+ ```
159
+
160
+ ## Changelog
161
+
162
+ O Changelog é gerado pela ferramenta [towncrier](https://github.com/twisted/towncrier).
163
+
164
+ Para criar o changelog da sua alteração, é necessário criar um arquivo em `changelog.d` com o sufixo desejado:
165
+
166
+ - ``.feature``: para nova funcionalidade
167
+ - ``.bugfix``: para correção de bug.
168
+ - ``.doc``: para uma melhoria na documentação.
169
+ - ``.removal``: para uma suspensão de uso ou remoção de API pública.
170
+ - ``.misc``: para uma alteração que não é de interesse do usuário.
171
+ - ``.health``: para refatoração, atualização de dependências.
172
+ - ``.security``: para correção de vulnerabilidade de segurança.
173
+
174
+ É recomendado como uso do prefixo o código do card referente ao MR.
175
+ Ex: ```TST123-xablau.feature```
176
+
177
+ Cada entrada do changelog deve estar em um arquivo separado.
178
+
179
+ ### Categorias do changelog
180
+
181
+ As categorias do changelog seguem o padrão [Keep a Changelog](https://keepachangelog.com/en/1.1.0/):
182
+
183
+ - **Added**: Nova funcionalidade.
184
+ - **Changed**: Alteração em funcionalidade existente (refatoração, atualização de dependências, melhorias internas).
185
+ - **Fixed**: Correção de bug.
186
+ - **Removed**: Remoção de funcionalidade.
187
+ - **Deprecated**: Funcionalidade que será removida em breve.
188
+ - **Security**: Correção de vulnerabilidade de segurança.
@@ -0,0 +1,9 @@
1
+ jwks_multi/__init__.py,sha256=5mOd-u1gE3Y7aEsatFLgbB1hxoEHwqNanGFmk_L362k,121
2
+ jwks_multi/jwks_multi_verifier.py,sha256=D2n8NG2GZSyR_V2eaO1qVVPs4LMYA69Xn3AmZCbBlDo,4908
3
+ jwks_multi/extentions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ jwks_multi/extentions/jwt_clains.py,sha256=FPSwIoBsStrkwb-wyHrV9gEzOwDB8yyCxWSVdcSKta8,900
5
+ jwks_multi-0.3.1.dist-info/licenses/LICENSE,sha256=ndtLWpaVLOp3HXS3jMqO8WXCDMMDg_fTwG13Eb-Iy5c,1067
6
+ jwks_multi-0.3.1.dist-info/METADATA,sha256=cNLpDlEuOamk_77v_Z5eNuq2ewRnZ2ZCQcz4I_715A4,5756
7
+ jwks_multi-0.3.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ jwks_multi-0.3.1.dist-info/top_level.txt,sha256=anTetxy9IaTup2gZ0NR3uQ-n31loTEg9JhDkYWCwajk,11
9
+ jwks_multi-0.3.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luizalabs
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.
22
+
@@ -0,0 +1 @@
1
+ jwks_multi