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.
Files changed (64) hide show
  1. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/PKG-INFO +1 -1
  2. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_validators.py +79 -55
  3. aws_python_helper-1.14.0/aws_python_helper/api/jwt_keys.py +139 -0
  4. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/PKG-INFO +1 -1
  5. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/SOURCES.txt +2 -0
  6. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/pyproject.toml +1 -1
  7. aws_python_helper-1.14.0/tests/test_jwt_keys.py +185 -0
  8. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/README.md +0 -0
  9. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/__init__.py +0 -0
  10. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/__init__.py +0 -0
  11. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/auth_middleware.py +0 -0
  12. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/base.py +0 -0
  13. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/dispatcher.py +0 -0
  14. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/exceptions.py +0 -0
  15. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/fetcher.py +0 -0
  16. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/api/handler.py +0 -0
  17. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/__init__.py +0 -0
  18. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/session.py +0 -0
  19. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/context/state_validator.py +0 -0
  20. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/__init__.py +0 -0
  21. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/database_proxy.py +0 -0
  22. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/external_database_proxy.py +0 -0
  23. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/external_mongo_manager.py +0 -0
  24. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/database/mongo_manager.py +0 -0
  25. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/__init__.py +0 -0
  26. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/executor.py +0 -0
  27. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/fetcher.py +0 -0
  28. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/handler.py +0 -0
  29. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/fargate/task_base.py +0 -0
  30. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/__init__.py +0 -0
  31. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/api_client.py +0 -0
  32. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/exceptions.py +0 -0
  33. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/invoker/lambda_invoker.py +0 -0
  34. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/__init__.py +0 -0
  35. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/base.py +0 -0
  36. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/fetcher.py +0 -0
  37. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/lambda_standalone/handler.py +0 -0
  38. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/__init__.py +0 -0
  39. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_index_sync/base.py +0 -0
  40. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/__init__.py +0 -0
  41. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/model_query/base.py +0 -0
  42. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/__init__.py +0 -0
  43. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/monitoring/run.py +0 -0
  44. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/__init__.py +0 -0
  45. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/permissions/catalog.py +0 -0
  46. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/__init__.py +0 -0
  47. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/audit.py +0 -0
  48. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/repository/base.py +0 -0
  49. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sns/__init__.py +0 -0
  50. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sns/publisher.py +0 -0
  51. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/__init__.py +0 -0
  52. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/consumer_base.py +0 -0
  53. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/fetcher.py +0 -0
  54. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/sqs/handler.py +0 -0
  55. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/__init__.py +0 -0
  56. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/json_encoder.py +0 -0
  57. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/response.py +0 -0
  58. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper/utils/serializer.py +0 -0
  59. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/dependency_links.txt +0 -0
  60. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/requires.txt +0 -0
  61. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/aws_python_helper.egg-info/top_level.txt +0 -0
  62. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/setup.cfg +0 -0
  63. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/tests/test_audit_stamp.py +0 -0
  64. {aws_python_helper-1.12.0 → aws_python_helper-1.14.0}/tests/test_sns_publisher.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.12.0
3
+ Version: 1.14.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -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. Check bypass token first (for development/testing)
67
- bypass_token = os.getenv('AUTH_BYPASS_TOKEN')
68
- if bypass_token and token == bypass_token:
69
- logger.info("Bypass token used - skipping DB validation")
70
- return {
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
- JWT_PUBLIC_KEY: RSA public key in PEM, raw or base64-encoded.
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
- bypass_token = os.getenv('AUTH_BYPASS_TOKEN')
187
- if bypass_token and token == bypass_token:
188
- logger.info("Bypass token used - skipping JWT validation")
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
- self._public_key(),
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-python-helper
3
- Version: 1.12.0
3
+ Version: 1.14.0
4
4
  Summary: AWS Python Helper Framework
5
5
  Author-email: Fabian Claros <neufabiae@gmail.com>
6
6
  License-Expression: MIT
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "aws-python-helper"
7
- version = "1.12.0"
7
+ version = "1.14.0"
8
8
  description = "AWS Python Helper Framework"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -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")