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 +6 -0
- jwks_multi/extentions/__init__.py +0 -0
- jwks_multi/extentions/jwt_clains.py +29 -0
- jwks_multi/jwks_multi_verifier.py +174 -0
- jwks_multi-0.3.1.dist-info/METADATA +188 -0
- jwks_multi-0.3.1.dist-info/RECORD +9 -0
- jwks_multi-0.3.1.dist-info/WHEEL +5 -0
- jwks_multi-0.3.1.dist-info/licenses/LICENSE +22 -0
- jwks_multi-0.3.1.dist-info/top_level.txt +1 -0
jwks_multi/__init__.py
ADDED
|
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,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
|