aws-python-helper 1.12.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.12.0 → aws_python_helper-1.14.0}/PKG-INFO +1 -1
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_validators.py +79 -55
- aws_python_helper-1.14.0/aws_python_helper/api/jwt_keys.py +139 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/PKG-INFO +1 -1
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/SOURCES.txt +2 -0
- {aws_python_helper-1.12.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.12.0 → aws_python_helper-1.14.0}/README.md +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_middleware.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/dispatcher.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/exceptions.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/fetcher.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/handler.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/session.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/state_validator.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/database_proxy.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/external_database_proxy.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/mongo_manager.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/executor.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/fetcher.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/handler.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/task_base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/api_client.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/exceptions.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/run.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/catalog.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/audit.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sns/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sns/publisher.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/consumer_base.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/fetcher.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/handler.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/__init__.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/json_encoder.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/response.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/serializer.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/requires.txt +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/top_level.txt +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/setup.cfg +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/tests/test_audit_stamp.py +0 -0
- {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/tests/test_sns_publisher.py +0 -0
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_validators.py
RENAMED
|
@@ -7,12 +7,63 @@ 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
|
|
|
13
14
|
logger = logging.getLogger(__name__)
|
|
14
15
|
|
|
15
16
|
|
|
17
|
+
def _machine_caller(token: str) -> Optional[Dict[str, Any]]:
|
|
18
|
+
"""Quién es, cuando quien llama no es una persona.
|
|
19
|
+
|
|
20
|
+
Dos tokens distintos, porque son dos cosas distintas y confundirlas deja una
|
|
21
|
+
puerta abierta más ancha de lo que nadie quiso:
|
|
22
|
+
|
|
23
|
+
- `INTER_SERVICE_TOKEN` dice **"soy un microservicio de los nuestros"**. Es
|
|
24
|
+
lo que un servicio manda cuando le habla a otro. No es un usuario y no
|
|
25
|
+
tiene permisos de nadie: un endpoint que lo acepta lo dice explícitamente.
|
|
26
|
+
- `AUTH_BYPASS_TOKEN` es la **llave maestra**, la forma de entrar cuando una
|
|
27
|
+
configuración de permisos quedó mal. Abre todo, y por eso no debería ser la
|
|
28
|
+
que un servicio usa todos los días.
|
|
29
|
+
|
|
30
|
+
Devuelve None si el token no es ninguno de los dos, y entonces se valida como
|
|
31
|
+
lo que es: el token de una persona.
|
|
32
|
+
"""
|
|
33
|
+
service_token = os.getenv('INTER_SERVICE_TOKEN')
|
|
34
|
+
if service_token and token == service_token:
|
|
35
|
+
logger.info("Inter-service token used")
|
|
36
|
+
return {
|
|
37
|
+
'user_id': 'service',
|
|
38
|
+
'user': {
|
|
39
|
+
'_id': 'service',
|
|
40
|
+
'email': 'service@system',
|
|
41
|
+
'name': 'Service',
|
|
42
|
+
'role': 'service',
|
|
43
|
+
},
|
|
44
|
+
'is_service': True,
|
|
45
|
+
'is_bypass': False,
|
|
46
|
+
'token_data': None,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
bypass_token = os.getenv('AUTH_BYPASS_TOKEN')
|
|
50
|
+
if bypass_token and token == bypass_token:
|
|
51
|
+
logger.info("Bypass token used - skipping validation")
|
|
52
|
+
return {
|
|
53
|
+
'user_id': 'bypass',
|
|
54
|
+
'user': {
|
|
55
|
+
'_id': 'bypass',
|
|
56
|
+
'email': 'bypass@system',
|
|
57
|
+
'name': 'Bypass User',
|
|
58
|
+
'role': 'admin',
|
|
59
|
+
},
|
|
60
|
+
'is_bypass': True,
|
|
61
|
+
'token_data': None,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
16
67
|
class AuthValidator(ABC):
|
|
17
68
|
"""
|
|
18
69
|
Abstract base class for authentication validators
|
|
@@ -63,21 +114,11 @@ class TokenValidator(AuthValidator):
|
|
|
63
114
|
UnauthorizedError: If token is invalid or expired
|
|
64
115
|
"""
|
|
65
116
|
|
|
66
|
-
# 1.
|
|
67
|
-
|
|
68
|
-
if
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
'user_id': 'bypass',
|
|
72
|
-
'user': {
|
|
73
|
-
'email': 'bypass@system',
|
|
74
|
-
'role': 'admin',
|
|
75
|
-
'name': 'Bypass User',
|
|
76
|
-
'_id': 'bypass'
|
|
77
|
-
},
|
|
78
|
-
'is_bypass': True,
|
|
79
|
-
'token_data': None
|
|
80
|
-
}
|
|
117
|
+
# 1. Quien llama puede no ser una persona: un servicio o la llave maestra.
|
|
118
|
+
caller = _machine_caller(token)
|
|
119
|
+
if caller:
|
|
120
|
+
return caller
|
|
121
|
+
|
|
81
122
|
|
|
82
123
|
# 2. Get database name from environment
|
|
83
124
|
db_name = os.getenv('AUTH_DB_NAME') or 'core'
|
|
@@ -151,52 +192,21 @@ class JWTValidator(AuthValidator):
|
|
|
151
192
|
touched. That is what lets any microservice authenticate a request without
|
|
152
193
|
calling the issuer.
|
|
153
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
|
+
|
|
154
198
|
Environment:
|
|
155
|
-
|
|
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.
|
|
156
201
|
JWT_ISSUER: expected `iss`. Defaults to 'constitution-core'.
|
|
157
202
|
JWT_AUDIENCE: expected `aud`. Only verified when set.
|
|
158
203
|
AUTH_BYPASS_TOKEN: still honoured, same as TokenValidator.
|
|
159
204
|
"""
|
|
160
205
|
|
|
161
|
-
_public_key_cache: Optional[str] = None
|
|
162
|
-
|
|
163
|
-
@classmethod
|
|
164
|
-
def _public_key(cls) -> str:
|
|
165
|
-
"""The configured public key, decoded once per container."""
|
|
166
|
-
if cls._public_key_cache:
|
|
167
|
-
return cls._public_key_cache
|
|
168
|
-
|
|
169
|
-
raw = os.getenv('JWT_PUBLIC_KEY')
|
|
170
|
-
if not raw:
|
|
171
|
-
raise ValueError(
|
|
172
|
-
"JWT_PUBLIC_KEY environment variable not set. "
|
|
173
|
-
"Required when AUTH_STRATEGY=jwt."
|
|
174
|
-
)
|
|
175
|
-
|
|
176
|
-
key = raw.strip()
|
|
177
|
-
if not key.startswith('-----BEGIN'):
|
|
178
|
-
# PEMs are multi-line, so they travel base64-encoded in env vars.
|
|
179
|
-
import base64
|
|
180
|
-
key = base64.b64decode(key).decode('utf-8')
|
|
181
|
-
|
|
182
|
-
cls._public_key_cache = key
|
|
183
|
-
return key
|
|
184
|
-
|
|
185
206
|
async def validate_token(self, token: str) -> Dict[str, Any]:
|
|
186
|
-
|
|
187
|
-
if
|
|
188
|
-
|
|
189
|
-
return {
|
|
190
|
-
'user_id': 'bypass',
|
|
191
|
-
'user': {
|
|
192
|
-
'email': 'bypass@system',
|
|
193
|
-
'role': 'admin',
|
|
194
|
-
'name': 'Bypass User',
|
|
195
|
-
'_id': 'bypass'
|
|
196
|
-
},
|
|
197
|
-
'is_bypass': True,
|
|
198
|
-
'token_data': None
|
|
199
|
-
}
|
|
207
|
+
caller = _machine_caller(token)
|
|
208
|
+
if caller:
|
|
209
|
+
return caller
|
|
200
210
|
|
|
201
211
|
try:
|
|
202
212
|
import jwt
|
|
@@ -205,11 +215,25 @@ class JWTValidator(AuthValidator):
|
|
|
205
215
|
"PyJWT is required for AUTH_STRATEGY=jwt. Install aws-python-helper[jwt]."
|
|
206
216
|
) from exc
|
|
207
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
|
+
|
|
208
232
|
audience = os.getenv('JWT_AUDIENCE')
|
|
209
233
|
try:
|
|
210
234
|
claims = jwt.decode(
|
|
211
235
|
token,
|
|
212
|
-
|
|
236
|
+
key,
|
|
213
237
|
algorithms=['RS256'],
|
|
214
238
|
issuer=os.getenv('JWT_ISSUER', 'constitution-core'),
|
|
215
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.12.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.12.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.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/state_validator.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/__init__.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.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.12.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.12.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.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/api_client.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/exceptions.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.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.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/base.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/fetcher.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/handler.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/__init__.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/base.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.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.12.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/__init__.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/catalog.py
RENAMED
|
File without changes
|
{aws_python_helper-1.12.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.12.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.12.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.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/requires.txt
RENAMED
|
File without changes
|
{aws_python_helper-1.12.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
|