nui-lambda-shared-utils 1.2.0__tar.gz → 1.2.1__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 (71) hide show
  1. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/PKG-INFO +1 -1
  2. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/__init__.py +3 -0
  3. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/jwt_auth.py +60 -1
  4. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_jwt_auth.py +112 -0
  5. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/.editorconfig +0 -0
  6. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/.github/workflows/ci.yml +0 -0
  7. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/.github/workflows/publish.yml +0 -0
  8. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/.github/workflows/test.yml +0 -0
  9. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/.markdownlint-cli2.yaml +0 -0
  10. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/CLAUDE.md +0 -0
  11. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/CONTRIBUTING.md +0 -0
  12. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/LICENSE +0 -0
  13. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/MANIFEST.in +0 -0
  14. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/README.md +0 -0
  15. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/README.md +0 -0
  16. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/development/testing.md +0 -0
  17. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/getting-started/configuration.md +0 -0
  18. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/getting-started/installation.md +0 -0
  19. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/getting-started/quickstart.md +0 -0
  20. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/cli-tools.md +0 -0
  21. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/elasticsearch-integration.md +0 -0
  22. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/jwt-authentication.md +0 -0
  23. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/lambda-utilities.md +0 -0
  24. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/log-processing.md +0 -0
  25. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/powertools-integration.md +0 -0
  26. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/shared-types.md +0 -0
  27. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/docs/guides/slack-integration.md +0 -0
  28. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/mypy.ini +0 -0
  29. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/base_client.py +0 -0
  30. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/cli.py +0 -0
  31. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/cloudwatch_metrics.py +0 -0
  32. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/config.py +0 -0
  33. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/db_client.py +0 -0
  34. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/error_handler.py +0 -0
  35. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/es_client.py +0 -0
  36. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/es_query_builder.py +0 -0
  37. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/lambda_helpers.py +0 -0
  38. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/log_processors.py +0 -0
  39. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/powertools_helpers.py +0 -0
  40. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/secrets_helper.py +0 -0
  41. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/slack_client.py +0 -0
  42. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/slack_formatter.py +0 -0
  43. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/slack_setup/__init__.py +0 -0
  44. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/slack_setup/channel_creator.py +0 -0
  45. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/slack_setup/channel_definitions.py +0 -0
  46. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/slack_setup/setup_helpers.py +0 -0
  47. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/timezone.py +0 -0
  48. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils/utils.py +0 -0
  49. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/nui_lambda_shared_utils.egg-info/SOURCES.txt +0 -0
  50. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/pyproject.toml +0 -0
  51. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/pytest.ini +0 -0
  52. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/requirements-test.txt +0 -0
  53. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/setup.cfg +0 -0
  54. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/setup.py +0 -0
  55. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/__init__.py +0 -0
  56. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_aws_utils.py +0 -0
  57. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_base_client.py +0 -0
  58. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_cloudwatch_metrics.py +0 -0
  59. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_config.py +0 -0
  60. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_db_client.py +0 -0
  61. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_error_handler.py +0 -0
  62. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_es_client.py +0 -0
  63. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_es_query_builder.py +0 -0
  64. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_lambda_helpers.py +0 -0
  65. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_log_processors.py +0 -0
  66. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_powertools_helpers.py +0 -0
  67. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_secrets_helper.py +0 -0
  68. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_slack_client.py +0 -0
  69. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_slack_formatter.py +0 -0
  70. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_timezone.py +0 -0
  71. {nui_lambda_shared_utils-1.2.0 → nui_lambda_shared_utils-1.2.1}/tests/test_utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nui-lambda-shared-utils
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Enterprise-grade utilities for AWS Lambda functions with Slack, Elasticsearch, and monitoring integrations
5
5
  Home-page: https://github.com/nuimarkets/nui-lambda-shared-utils
6
6
  Author: NUI Markets
@@ -137,6 +137,7 @@ try:
137
137
  from .jwt_auth import (
138
138
  validate_jwt,
139
139
  require_auth,
140
+ check_auth,
140
141
  get_jwt_public_key,
141
142
  JWTValidationError,
142
143
  AuthenticationError,
@@ -144,6 +145,7 @@ try:
144
145
  except ImportError:
145
146
  validate_jwt = None # type: ignore
146
147
  require_auth = None # type: ignore
148
+ check_auth = None # type: ignore
147
149
  get_jwt_public_key = None # type: ignore
148
150
  JWTValidationError = None # type: ignore
149
151
  AuthenticationError = None # type: ignore
@@ -243,6 +245,7 @@ __all__ = [
243
245
  # JWT authentication
244
246
  "validate_jwt",
245
247
  "require_auth",
248
+ "check_auth",
246
249
  "get_jwt_public_key",
247
250
  "JWTValidationError",
248
251
  "AuthenticationError",
@@ -9,10 +9,12 @@ Install: pip install nui-lambda-shared-utils[jwt]
9
9
 
10
10
  import os
11
11
  import json
12
+ import re
12
13
  import base64
13
14
  import time
14
15
  import logging
15
- from typing import TYPE_CHECKING, Optional
16
+ from typing import TYPE_CHECKING, AbstractSet, Any, Dict, Optional, Tuple
17
+ from urllib.parse import unquote
16
18
 
17
19
  from .secrets_helper import get_secret
18
20
 
@@ -216,3 +218,60 @@ def require_auth(event: dict, secret_name: Optional[str] = None) -> dict:
216
218
  return validate_jwt(token, public_key)
217
219
  except JWTValidationError as e:
218
220
  raise AuthenticationError(f"Authentication failed: {e}") from e
221
+
222
+
223
+ def _normalize_path(path: str) -> str:
224
+ """Normalize a URL path for safe comparison.
225
+
226
+ URL-decodes, collapses duplicate slashes, ensures a single leading slash,
227
+ and strips any trailing slash (except for root "/").
228
+ """
229
+ path = unquote(path)
230
+ path = re.sub(r"/+", "/", path)
231
+ return "/" + path.strip("/") if path.strip("/") else "/"
232
+
233
+
234
+ def check_auth(
235
+ event: dict,
236
+ public_paths: AbstractSet[str] = frozenset(),
237
+ secret_name: Optional[str] = None,
238
+ ) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
239
+ """Check JWT authentication on an API Gateway event, skipping public paths.
240
+
241
+ Combines path normalization, public-path bypass, JWT validation,
242
+ and a standard JSON:API 401 error response in one call.
243
+
244
+ Args:
245
+ event: API Gateway Lambda proxy integration event.
246
+ public_paths: Set of normalized paths that skip auth (e.g. {"/health"}).
247
+ secret_name: Optional Secrets Manager secret name for the public key.
248
+
249
+ Returns:
250
+ (claims, None) on success — claims is the decoded JWT dict,
251
+ or None if the path is public.
252
+ (None, response) on auth failure — response is a 401 dict
253
+ ready to return from your Lambda handler.
254
+ """
255
+ raw_path = event.get("path") or event.get("rawPath") or ""
256
+ if _normalize_path(raw_path) in public_paths:
257
+ return None, None
258
+
259
+ try:
260
+ claims = require_auth(event, secret_name=secret_name)
261
+ return claims, None
262
+ except AuthenticationError as e:
263
+ log.warning("Authentication failed: %s", e)
264
+ return None, {
265
+ "statusCode": 401,
266
+ "headers": {
267
+ "Content-Type": "application/json",
268
+ "Access-Control-Allow-Origin": "*",
269
+ },
270
+ "body": json.dumps({
271
+ "errors": [{
272
+ "status": "401",
273
+ "title": "Unauthorized",
274
+ "detail": "Authentication required",
275
+ }]
276
+ }),
277
+ }
@@ -15,10 +15,12 @@ import rsa as rsa_lib
15
15
  from nui_lambda_shared_utils.jwt_auth import (
16
16
  validate_jwt,
17
17
  require_auth,
18
+ check_auth,
18
19
  get_jwt_public_key,
19
20
  JWTValidationError,
20
21
  AuthenticationError,
21
22
  _base64url_decode,
23
+ _normalize_path,
22
24
  )
23
25
 
24
26
 
@@ -274,3 +276,113 @@ class TestGetJwtPublicKey:
274
276
 
275
277
  with pytest.raises(JWTValidationError, match="Field 'TOKEN_PUBLIC_KEY' not found"):
276
278
  get_jwt_public_key(secret_name="test/jwt-key")
279
+
280
+
281
+ # ---------------------------------------------------------------------------
282
+ # _normalize_path tests
283
+ # ---------------------------------------------------------------------------
284
+
285
+
286
+ @pytest.mark.unit
287
+ class TestNormalizePath:
288
+ def test_simple_path(self):
289
+ assert _normalize_path("/health") == "/health"
290
+
291
+ def test_strips_trailing_slash(self):
292
+ assert _normalize_path("/health/") == "/health"
293
+
294
+ def test_ensures_leading_slash(self):
295
+ assert _normalize_path("health") == "/health"
296
+
297
+ def test_collapses_duplicate_slashes(self):
298
+ assert _normalize_path("//health///check//") == "/health/check"
299
+
300
+ def test_url_decodes(self):
301
+ assert _normalize_path("/he%61lth") == "/health"
302
+
303
+ def test_root_path(self):
304
+ assert _normalize_path("/") == "/"
305
+
306
+ def test_empty_string(self):
307
+ assert _normalize_path("") == "/"
308
+
309
+ def test_full_api_gateway_path(self):
310
+ assert _normalize_path("/v4/fields/static-lists") == "/v4/fields/static-lists"
311
+
312
+
313
+ # ---------------------------------------------------------------------------
314
+ # check_auth tests
315
+ # ---------------------------------------------------------------------------
316
+
317
+
318
+ @pytest.mark.unit
319
+ class TestCheckAuth:
320
+ def test_public_path_skips_auth(self):
321
+ """Public paths return (None, None) without checking JWT."""
322
+ event = {"path": "/health", "headers": {}}
323
+ claims, error = check_auth(event, public_paths={"/health"})
324
+ assert claims is None
325
+ assert error is None
326
+
327
+ def test_public_path_with_trailing_slash(self):
328
+ """Public path matching normalizes trailing slashes."""
329
+ event = {"path": "/health/", "headers": {}}
330
+ claims, error = check_auth(event, public_paths={"/health"})
331
+ assert claims is None
332
+ assert error is None
333
+
334
+ def test_non_public_path_without_token_returns_401(self):
335
+ """Missing auth on protected path returns 401 response."""
336
+ event = {"path": "/static-lists", "headers": {}}
337
+ claims, error = check_auth(event, public_paths={"/health"})
338
+ assert claims is None
339
+ assert error is not None
340
+ assert error["statusCode"] == 401
341
+ body = json.loads(error["body"])
342
+ assert body["errors"][0]["status"] == "401"
343
+ assert body["errors"][0]["detail"] == "Authentication required"
344
+
345
+ @pytest.mark.usefixtures("mock_jwt_secret")
346
+ def test_valid_auth_returns_claims(self, rsa_keypair):
347
+ """Valid JWT returns (claims, None)."""
348
+ private_key, _, _ = rsa_keypair
349
+ token = _sign_jwt(private_key, {"sub": "user1", "exp": time.time() + 3600})
350
+ event = {"path": "/static-lists", "headers": {"Authorization": f"Bearer {token}"}}
351
+
352
+ with patch.dict("os.environ", {"JWT_PUBLIC_KEY_SECRET": "test/jwt-key"}):
353
+ claims, error = check_auth(event, public_paths={"/health"})
354
+
355
+ assert error is None
356
+ assert claims["sub"] == "user1"
357
+
358
+ @pytest.mark.usefixtures("mock_jwt_secret")
359
+ def test_invalid_token_returns_401(self):
360
+ """Invalid JWT returns 401 response, not an exception."""
361
+ event = {"path": "/static-lists", "headers": {"Authorization": "Bearer bad.token.here"}}
362
+
363
+ with patch.dict("os.environ", {"JWT_PUBLIC_KEY_SECRET": "test/jwt-key"}):
364
+ claims, error = check_auth(event, public_paths={"/health"})
365
+
366
+ assert claims is None
367
+ assert error["statusCode"] == 401
368
+
369
+ def test_path_traversal_does_not_bypass_auth(self):
370
+ """Crafted paths like /anything/health don't bypass auth."""
371
+ event = {"path": "/anything/health", "headers": {}}
372
+ _, error = check_auth(event, public_paths={"/health"})
373
+ assert error is not None
374
+ assert error["statusCode"] == 401
375
+
376
+ def test_uses_rawPath_fallback(self):
377
+ """Falls back to rawPath when path is missing (API Gateway v2)."""
378
+ event = {"rawPath": "/health", "headers": {}}
379
+ claims, error = check_auth(event, public_paths={"/health"})
380
+ assert claims is None
381
+ assert error is None
382
+
383
+ def test_empty_public_paths_requires_auth_on_all(self):
384
+ """Empty public_paths means all paths require auth."""
385
+ event = {"path": "/health", "headers": {}}
386
+ _, error = check_auth(event)
387
+ assert error is not None
388
+ assert error["statusCode"] == 401