aws-python-helper 1.13.0__tar.gz → 1.14.0__tar.gz
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.
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/PKG-INFO +1 -1
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_validators.py +21 -26
- aws_python_helper-1.14.0/aws_python_helper/api/jwt_keys.py +139 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/PKG-INFO +1 -1
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/SOURCES.txt +2 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/pyproject.toml +1 -1
- aws_python_helper-1.14.0/tests/test_jwt_keys.py +185 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/README.md +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_middleware.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/dispatcher.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/exceptions.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/fetcher.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/handler.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/context/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/context/session.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/context/state_validator.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/database_proxy.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/external_database_proxy.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/mongo_manager.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/executor.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/fetcher.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/handler.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/task_base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/api_client.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/exceptions.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/run.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/catalog.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/audit.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sns/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sns/publisher.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/consumer_base.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/fetcher.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/handler.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/__init__.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/json_encoder.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/response.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/serializer.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/requires.txt +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/top_level.txt +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/setup.cfg +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/tests/test_audit_stamp.py +0 -0
- {aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/tests/test_sns_publisher.py +0 -0
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_validators.py
RENAMED
|
@@ -7,6 +7,7 @@ from typing import Dict, Any, Optional
|
|
|
7
7
|
import os
|
|
8
8
|
import logging
|
|
9
9
|
from datetime import datetime
|
|
10
|
+
from . import jwt_keys
|
|
10
11
|
from .exceptions import UnauthorizedError
|
|
11
12
|
from ..database.mongo_manager import MongoManager
|
|
12
13
|
|
|
@@ -191,37 +192,17 @@ class JWTValidator(AuthValidator):
|
|
|
191
192
|
touched. That is what lets any microservice authenticate a request without
|
|
192
193
|
calling the issuer.
|
|
193
194
|
|
|
195
|
+
The key comes from core, which publishes it: see `jwt_keys`. A service that
|
|
196
|
+
pins JWT_PUBLIC_KEY uses that instead and never calls out.
|
|
197
|
+
|
|
194
198
|
Environment:
|
|
195
|
-
|
|
199
|
+
CORE_API_URL: where core answers. How the key is found.
|
|
200
|
+
JWT_PUBLIC_KEY: pins the key instead of reading it from core.
|
|
196
201
|
JWT_ISSUER: expected `iss`. Defaults to 'constitution-core'.
|
|
197
202
|
JWT_AUDIENCE: expected `aud`. Only verified when set.
|
|
198
203
|
AUTH_BYPASS_TOKEN: still honoured, same as TokenValidator.
|
|
199
204
|
"""
|
|
200
205
|
|
|
201
|
-
_public_key_cache: Optional[str] = None
|
|
202
|
-
|
|
203
|
-
@classmethod
|
|
204
|
-
def _public_key(cls) -> str:
|
|
205
|
-
"""The configured public key, decoded once per container."""
|
|
206
|
-
if cls._public_key_cache:
|
|
207
|
-
return cls._public_key_cache
|
|
208
|
-
|
|
209
|
-
raw = os.getenv('JWT_PUBLIC_KEY')
|
|
210
|
-
if not raw:
|
|
211
|
-
raise ValueError(
|
|
212
|
-
"JWT_PUBLIC_KEY environment variable not set. "
|
|
213
|
-
"Required when AUTH_STRATEGY=jwt."
|
|
214
|
-
)
|
|
215
|
-
|
|
216
|
-
key = raw.strip()
|
|
217
|
-
if not key.startswith('-----BEGIN'):
|
|
218
|
-
# PEMs are multi-line, so they travel base64-encoded in env vars.
|
|
219
|
-
import base64
|
|
220
|
-
key = base64.b64decode(key).decode('utf-8')
|
|
221
|
-
|
|
222
|
-
cls._public_key_cache = key
|
|
223
|
-
return key
|
|
224
|
-
|
|
225
206
|
async def validate_token(self, token: str) -> Dict[str, Any]:
|
|
226
207
|
caller = _machine_caller(token)
|
|
227
208
|
if caller:
|
|
@@ -234,11 +215,25 @@ class JWTValidator(AuthValidator):
|
|
|
234
215
|
"PyJWT is required for AUTH_STRATEGY=jwt. Install aws-python-helper[jwt]."
|
|
235
216
|
) from exc
|
|
236
217
|
|
|
218
|
+
# El kid del header dice con qué clave se firmó. Es dato sin verificar y
|
|
219
|
+
# sólo se usa para elegir la clave: la firma se comprueba igual, así que
|
|
220
|
+
# un kid mentido no abre nada, simplemente no encuentra clave.
|
|
221
|
+
try:
|
|
222
|
+
kid = jwt.get_unverified_header(token).get('kid')
|
|
223
|
+
except jwt.InvalidTokenError:
|
|
224
|
+
raise UnauthorizedError("Invalid token")
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
key = await jwt_keys.resolve(kid)
|
|
228
|
+
except ValueError as exc:
|
|
229
|
+
logger.error("Cannot verify tokens: %s", exc)
|
|
230
|
+
raise
|
|
231
|
+
|
|
237
232
|
audience = os.getenv('JWT_AUDIENCE')
|
|
238
233
|
try:
|
|
239
234
|
claims = jwt.decode(
|
|
240
235
|
token,
|
|
241
|
-
|
|
236
|
+
key,
|
|
242
237
|
algorithms=['RS256'],
|
|
243
238
|
issuer=os.getenv('JWT_ISSUER', 'constitution-core'),
|
|
244
239
|
audience=audience,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Where a service gets the key that verifies core's signatures.
|
|
2
|
+
|
|
3
|
+
Core signs; everybody else verifies. The key is public —with it you check a
|
|
4
|
+
signature, you cannot produce one— so the only real question is how it travels.
|
|
5
|
+
|
|
6
|
+
It used to travel as an environment variable, which meant the same public fact
|
|
7
|
+
sat in every service's secret. Five copies of one thing: rotating the key was
|
|
8
|
+
five edits and five deploys, and any one of them could be the stale one, which
|
|
9
|
+
fails as "invalid token" and looks like a problem with the user.
|
|
10
|
+
|
|
11
|
+
So core publishes it at `GET /jwks` —public, because a service reads it exactly
|
|
12
|
+
when it cannot authenticate anything yet— and the services read it from there.
|
|
13
|
+
Nothing to copy, nothing to keep in sync, and rotation becomes something core
|
|
14
|
+
does alone.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
PATH = "/jwks"
|
|
26
|
+
|
|
27
|
+
# Un kid desconocido puede ser una rotación recién hecha o un token inventado.
|
|
28
|
+
# Volver a preguntar cubre lo primero; el piso evita que lo segundo se convierta
|
|
29
|
+
# en una llamada a core por cada request.
|
|
30
|
+
REFETCH_FLOOR_SECONDS = 60
|
|
31
|
+
TIMEOUT_SECONDS = 5
|
|
32
|
+
|
|
33
|
+
_keys: Dict[str, Any] = {}
|
|
34
|
+
_last_fetch: Optional[float] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def configured_key() -> Optional[str]:
|
|
38
|
+
"""The key pinned in the environment, if there is one.
|
|
39
|
+
|
|
40
|
+
Core holds the pair and verifies its own tokens, so asking it to fetch over
|
|
41
|
+
HTTP something it already has would be a service calling itself. It is also
|
|
42
|
+
the way out: set the variable anywhere and that service stops depending on
|
|
43
|
+
the endpoint.
|
|
44
|
+
"""
|
|
45
|
+
raw = (os.getenv('JWT_PUBLIC_KEY') or '').strip()
|
|
46
|
+
if not raw:
|
|
47
|
+
return None
|
|
48
|
+
if raw.startswith('-----BEGIN'):
|
|
49
|
+
return raw
|
|
50
|
+
# PEMs are multi-line, so they travel base64-encoded in env vars.
|
|
51
|
+
return base64.b64decode(raw).decode('utf-8')
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def reset() -> None:
|
|
55
|
+
"""Forget what was fetched. For tests."""
|
|
56
|
+
global _last_fetch
|
|
57
|
+
_keys.clear()
|
|
58
|
+
_last_fetch = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _core_url() -> str:
|
|
62
|
+
url = (os.getenv('CORE_API_URL') or '').rstrip('/')
|
|
63
|
+
if not url:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"Neither JWT_PUBLIC_KEY nor CORE_API_URL is set: this service has no "
|
|
66
|
+
"way to learn the key that verifies core's tokens."
|
|
67
|
+
)
|
|
68
|
+
return url
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _lookup(kid: Optional[str]) -> Optional[Any]:
|
|
72
|
+
if not _keys:
|
|
73
|
+
return None
|
|
74
|
+
if kid:
|
|
75
|
+
return _keys.get(kid)
|
|
76
|
+
# Sin kid sólo hay una respuesta sensata si core publica una sola clave. Con
|
|
77
|
+
# dos, elegir una sería aceptar como buena una firma que quizá no lo es.
|
|
78
|
+
return next(iter(_keys.values())) if len(_keys) == 1 else None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def _refresh() -> None:
|
|
82
|
+
global _last_fetch
|
|
83
|
+
_last_fetch = time.monotonic()
|
|
84
|
+
|
|
85
|
+
import httpx
|
|
86
|
+
from jwt.algorithms import RSAAlgorithm
|
|
87
|
+
|
|
88
|
+
url = f"{_core_url()}{PATH}"
|
|
89
|
+
try:
|
|
90
|
+
async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS) as client:
|
|
91
|
+
response = await client.get(url)
|
|
92
|
+
response.raise_for_status()
|
|
93
|
+
published = response.json()
|
|
94
|
+
except Exception as exc:
|
|
95
|
+
raise ValueError(f"Could not read the signing keys from {url}: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
found = {}
|
|
98
|
+
for entry in published.get('keys') or []:
|
|
99
|
+
kid = entry.get('kid')
|
|
100
|
+
if not kid:
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
found[kid] = RSAAlgorithm.from_jwk(entry)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
# Una clave ilegible no invalida a las demás: la que sirve, sirve.
|
|
106
|
+
logger.warning("Ignoring key %s published by core: %s", kid, exc)
|
|
107
|
+
|
|
108
|
+
if not found:
|
|
109
|
+
raise ValueError(f"{url} published no usable signing key")
|
|
110
|
+
|
|
111
|
+
_keys.clear()
|
|
112
|
+
_keys.update(found)
|
|
113
|
+
logger.info("Signing keys loaded from core: %s", ", ".join(sorted(_keys)))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def resolve(kid: Optional[str] = None) -> Any:
|
|
117
|
+
"""The key that verifies a token signed with `kid`.
|
|
118
|
+
|
|
119
|
+
Cached for the life of the container: one call per cold start, not per
|
|
120
|
+
request.
|
|
121
|
+
"""
|
|
122
|
+
pinned = configured_key()
|
|
123
|
+
if pinned:
|
|
124
|
+
return pinned
|
|
125
|
+
|
|
126
|
+
key = _lookup(kid)
|
|
127
|
+
if key is not None:
|
|
128
|
+
return key
|
|
129
|
+
|
|
130
|
+
if _last_fetch is None or (time.monotonic() - _last_fetch) >= REFETCH_FLOOR_SECONDS:
|
|
131
|
+
await _refresh()
|
|
132
|
+
key = _lookup(kid)
|
|
133
|
+
|
|
134
|
+
if key is None:
|
|
135
|
+
raise ValueError(
|
|
136
|
+
f"Core published no signing key with kid {kid!r}. "
|
|
137
|
+
"A token signed with a retired key looks exactly like this."
|
|
138
|
+
)
|
|
139
|
+
return key
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/SOURCES.txt
RENAMED
|
@@ -14,6 +14,7 @@ aws_python_helper/api/dispatcher.py
|
|
|
14
14
|
aws_python_helper/api/exceptions.py
|
|
15
15
|
aws_python_helper/api/fetcher.py
|
|
16
16
|
aws_python_helper/api/handler.py
|
|
17
|
+
aws_python_helper/api/jwt_keys.py
|
|
17
18
|
aws_python_helper/context/__init__.py
|
|
18
19
|
aws_python_helper/context/session.py
|
|
19
20
|
aws_python_helper/context/state_validator.py
|
|
@@ -57,4 +58,5 @@ aws_python_helper/utils/json_encoder.py
|
|
|
57
58
|
aws_python_helper/utils/response.py
|
|
58
59
|
aws_python_helper/utils/serializer.py
|
|
59
60
|
tests/test_audit_stamp.py
|
|
61
|
+
tests/test_jwt_keys.py
|
|
60
62
|
tests/test_sns_publisher.py
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""A real key, a real token, and core's own code publishing it.
|
|
2
|
+
|
|
3
|
+
The point of testing it this way: every part of this chain has a mockable seam,
|
|
4
|
+
and mocking any of them would have hidden the bug it was written to catch. The
|
|
5
|
+
key is generated here, core's controller turns it into JWKS, the transport is
|
|
6
|
+
faked one layer below httpx, and PyJWT verifies the signature for real.
|
|
7
|
+
|
|
8
|
+
Runs with plain `python tests/test_jwt_keys.py`.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import base64
|
|
13
|
+
import importlib.util
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
import jwt as pyjwt
|
|
23
|
+
from cryptography.hazmat.primitives import serialization
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
25
|
+
|
|
26
|
+
from aws_python_helper.api import jwt_keys
|
|
27
|
+
|
|
28
|
+
# El controlador de core vive en otro repo, al lado de éste. Si no está, la
|
|
29
|
+
# parte que lo usa se saltea en vez de dar una falla que no es del framework.
|
|
30
|
+
CORE_SRC = os.path.abspath(os.path.join(
|
|
31
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
|
|
32
|
+
"..", "constitution-core", "src"))
|
|
33
|
+
CORE = os.path.join(CORE_SRC, "api", "jwks", "list.py")
|
|
34
|
+
|
|
35
|
+
failures = []
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def check(label, condition):
|
|
39
|
+
print(f" {'ok ' if condition else 'FALLA'} {label}")
|
|
40
|
+
if not condition:
|
|
41
|
+
failures.append(label)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def load_core_controller():
|
|
45
|
+
"""Se carga por ruta, que es como lo carga el fetcher en la lambda."""
|
|
46
|
+
# En la lambda el cwd es la raíz del paquete, así que `helpers` se importa
|
|
47
|
+
# por nombre absoluto. Acá hay que ponerla a mano.
|
|
48
|
+
if CORE_SRC not in sys.path:
|
|
49
|
+
sys.path.insert(0, CORE_SRC)
|
|
50
|
+
spec = importlib.util.spec_from_file_location("core_jwt_keys", os.path.abspath(CORE))
|
|
51
|
+
module = importlib.util.module_from_spec(spec)
|
|
52
|
+
spec.loader.exec_module(module)
|
|
53
|
+
return module
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def make_key():
|
|
57
|
+
private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
|
58
|
+
pem_private = private.private_bytes(
|
|
59
|
+
serialization.Encoding.PEM,
|
|
60
|
+
serialization.PrivateFormat.PKCS8,
|
|
61
|
+
serialization.NoEncryption()).decode()
|
|
62
|
+
pem_public = private.public_key().public_bytes(
|
|
63
|
+
serialization.Encoding.PEM,
|
|
64
|
+
serialization.PublicFormat.SubjectPublicKeyInfo).decode()
|
|
65
|
+
return pem_private, pem_public
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class FakeCore:
|
|
69
|
+
"""Habla el idioma de httpx, no el de nuestro código."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, body, status=200):
|
|
72
|
+
self.body, self.status, self.calls = body, status, 0
|
|
73
|
+
|
|
74
|
+
def install(self):
|
|
75
|
+
fake = self
|
|
76
|
+
|
|
77
|
+
class Client:
|
|
78
|
+
def __init__(self, **kwargs): pass
|
|
79
|
+
async def __aenter__(self): return self
|
|
80
|
+
async def __aexit__(self, *a): return False
|
|
81
|
+
async def get(self, url):
|
|
82
|
+
fake.calls += 1
|
|
83
|
+
if fake.status >= 400:
|
|
84
|
+
raise httpx.ConnectError("core no responde")
|
|
85
|
+
return httpx.Response(fake.status, json=fake.body,
|
|
86
|
+
request=httpx.Request("GET", url))
|
|
87
|
+
|
|
88
|
+
httpx.AsyncClient = Client
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
original_client = httpx.AsyncClient
|
|
92
|
+
pem_private, pem_public = make_key()
|
|
93
|
+
|
|
94
|
+
if not os.path.exists(CORE):
|
|
95
|
+
print(f"constitution-core no está en {CORE_SRC}: se saltea la parte de core")
|
|
96
|
+
sys.exit(0)
|
|
97
|
+
|
|
98
|
+
print("== core arma el JWKS con su propio código ==")
|
|
99
|
+
os.environ["JWT_PRIVATE_KEY"] = pem_private
|
|
100
|
+
os.environ["JWT_KID"] = "core-2026"
|
|
101
|
+
load_core_controller()
|
|
102
|
+
from helpers import jwt_issuer
|
|
103
|
+
jwks = {"keys": [jwt_issuer.public_jwk()]}
|
|
104
|
+
check("publica un kid", jwks["keys"][0]["kid"] == "core-2026")
|
|
105
|
+
check("es una clave RSA para firmar", jwks["keys"][0]["kty"] == "RSA" and jwks["keys"][0]["alg"] == "RS256")
|
|
106
|
+
check("no filtra la clave privada", "PRIVATE" not in json.dumps(jwks))
|
|
107
|
+
|
|
108
|
+
print("\n== un servicio sin la clave la baja de core ==")
|
|
109
|
+
os.environ["CORE_API_URL"] = "https://core.example"
|
|
110
|
+
jwt_keys.reset()
|
|
111
|
+
fake = FakeCore(jwks)
|
|
112
|
+
fake.install()
|
|
113
|
+
|
|
114
|
+
token = pyjwt.encode({"sub": "u1", "email": "a@b.c", "iss": "constitution-core"},
|
|
115
|
+
pem_private, algorithm="RS256", headers={"kid": "core-2026"})
|
|
116
|
+
|
|
117
|
+
from aws_python_helper.api.auth_validators import JWTValidator
|
|
118
|
+
auth = asyncio.run(JWTValidator().validate_token(token))
|
|
119
|
+
check("verifica un token real firmado por core", auth["user_id"] == "u1")
|
|
120
|
+
check("una sola llamada a core", fake.calls == 1)
|
|
121
|
+
|
|
122
|
+
asyncio.run(JWTValidator().validate_token(token))
|
|
123
|
+
check("el segundo request no vuelve a preguntar", fake.calls == 1)
|
|
124
|
+
|
|
125
|
+
print("\n== una firma que no es de core no pasa ==")
|
|
126
|
+
_, otro_public = make_key()
|
|
127
|
+
otro_private, _ = make_key()
|
|
128
|
+
falso = pyjwt.encode({"sub": "u1", "iss": "constitution-core"}, otro_private,
|
|
129
|
+
algorithm="RS256", headers={"kid": "core-2026"})
|
|
130
|
+
try:
|
|
131
|
+
asyncio.run(JWTValidator().validate_token(falso))
|
|
132
|
+
check("rechaza una firma ajena", False)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
check(f"rechaza una firma ajena ({type(exc).__name__})", True)
|
|
135
|
+
|
|
136
|
+
print("\n== rotación: un kid nuevo hace preguntar de nuevo ==")
|
|
137
|
+
nuevo_private, nuevo_public = make_key()
|
|
138
|
+
os.environ["JWT_PRIVATE_KEY"] = nuevo_private
|
|
139
|
+
os.environ["JWT_KID"] = "core-2027"
|
|
140
|
+
# Core cachea la clave por contenedor, que es lo correcto en una lambda. Acá hay
|
|
141
|
+
# que soltarla para poder simular una rotación en el mismo proceso.
|
|
142
|
+
jwt_issuer._private_key_cache = None
|
|
143
|
+
jwks_rotado = {"keys": [jwt_issuer.public_jwk()]}
|
|
144
|
+
fake.body = jwks_rotado
|
|
145
|
+
jwt_keys._last_fetch = time.monotonic() - jwt_keys.REFETCH_FLOOR_SECONDS - 1
|
|
146
|
+
rotado = pyjwt.encode({"sub": "u2", "iss": "constitution-core"}, nuevo_private,
|
|
147
|
+
algorithm="RS256", headers={"kid": "core-2027"})
|
|
148
|
+
auth = asyncio.run(JWTValidator().validate_token(rotado))
|
|
149
|
+
check("acepta el token de la clave nueva", auth["user_id"] == "u2")
|
|
150
|
+
check("volvió a preguntar una sola vez", fake.calls == 2)
|
|
151
|
+
|
|
152
|
+
print("\n== un kid inventado no dispara una llamada por request ==")
|
|
153
|
+
antes = fake.calls
|
|
154
|
+
inventado = pyjwt.encode({"sub": "x", "iss": "constitution-core"}, nuevo_private,
|
|
155
|
+
algorithm="RS256", headers={"kid": "no-existe"})
|
|
156
|
+
for _ in range(3):
|
|
157
|
+
try:
|
|
158
|
+
asyncio.run(JWTValidator().validate_token(inventado))
|
|
159
|
+
except Exception:
|
|
160
|
+
pass
|
|
161
|
+
check("el piso de tiempo aguanta", fake.calls == antes)
|
|
162
|
+
|
|
163
|
+
print("\n== la clave fijada gana, y core no se llama a sí mismo ==")
|
|
164
|
+
os.environ["JWT_PUBLIC_KEY"] = nuevo_public
|
|
165
|
+
antes = fake.calls
|
|
166
|
+
auth = asyncio.run(JWTValidator().validate_token(rotado))
|
|
167
|
+
check("usa la del ambiente", auth["user_id"] == "u2")
|
|
168
|
+
check("no llamó a core", fake.calls == antes)
|
|
169
|
+
|
|
170
|
+
print("\n== sin clave y sin CORE_API_URL, el error dice qué falta ==")
|
|
171
|
+
del os.environ["JWT_PUBLIC_KEY"]
|
|
172
|
+
del os.environ["CORE_API_URL"]
|
|
173
|
+
jwt_keys.reset()
|
|
174
|
+
try:
|
|
175
|
+
asyncio.run(JWTValidator().validate_token(rotado))
|
|
176
|
+
check("explica el problema", False)
|
|
177
|
+
except ValueError as exc:
|
|
178
|
+
check(f"explica el problema: {exc}"[:90], "CORE_API_URL" in str(exc))
|
|
179
|
+
|
|
180
|
+
httpx.AsyncClient = original_client
|
|
181
|
+
print()
|
|
182
|
+
if failures:
|
|
183
|
+
print(f"{len(failures)} fallas")
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
print("all good")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_middleware.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/context/state_validator.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/__init__.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/database_proxy.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/database/mongo_manager.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/task_base.py
RENAMED
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/api_client.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/exceptions.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/lambda_invoker.py
RENAMED
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/base.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/fetcher.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/handler.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/__init__.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/base.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/__init__.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/catalog.py
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/consumer_base.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/json_encoder.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/requires.txt
RENAMED
|
File without changes
|
{aws_python_helper-1.13.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|